CSS offset-path: move an element along a curve

offset-path gives an element a route and offset-distance says how far along it the element is. Animate the distance and the element travels the curve, with no JavaScript.

To move an element along a curve in CSS, give it an offset-path and animate offset-distance from 0% to 100%. The path is the route, and the distance is how far along the route the element is. No JavaScript is involved.

Here is a dot following a curve. The dashed line is the same path, drawn with SVG so you can see it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A dot following a curve</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  /* the SVG and the dot share this box, so the path coordinates line up */
  .stage { position: relative; width: 340px; height: 200px; margin: 0 auto; background: #fff; border-radius: 12px; }
  .stage svg { position: absolute; left: 0; top: 0; }
  .dot {
    position: absolute; left: 0; top: 0;       /* path() starts from here */
    width: 18px; height: 18px; border-radius: 50%;
    background: #2563eb; box-shadow: 0 0 0 5px rgba(37, 99, 235, .2);
    offset-path: path('M 20 170 C 70 20, 150 20, 170 100 S 270 190, 320 30');
    animation: follow 3s ease-in-out infinite alternate;
  }
  @keyframes follow {
    from { offset-distance: 0%; }
    to   { offset-distance: 100%; }
  }
  p { margin: 10px 0 0; text-align: center; font-size: 13px; color: #5b6270; }
</style>
</head>
<body>
<div class="stage">
  <!-- the same path, drawn so you can see it -->
  <svg width="340" height="200" aria-hidden="true">
    <path d="M 20 170 C 70 20, 150 20, 170 100 S 270 190, 320 30"
          fill="none" stroke="#c9cfd8" stroke-width="3" stroke-dasharray="6 6"/>
  </svg>
  <div class="dot"></div>
</div>
<p>No JavaScript: offset-path draws the route, offset-distance moves the dot.</p>
</body>
</html>
One offset-path, one @keyframes rule. Edit the path numbers and the route changes.

The whole motion is these lines:

.dot {
  position: absolute; left: 0; top: 0;
  offset-path: path('M 20 170 C 70 20, 150 20, 170 100 S 270 190, 320 30');
  animation: follow 3s ease-in-out infinite alternate;
}
@keyframes follow {
  from { offset-distance: 0%; }
  to   { offset-distance: 100%; }
}

Draw the path once, use it twice

path() takes the same path data as the d attribute of an SVG <path>: M moves, L draws a line, C and S draw curves. You can copy a d string from a drawing tool or from an SVG file and paste it in.

The numbers are CSS pixels, and 0 0 is the element's own top-left corner, where it would sit without the path. That is why the dot above has left: 0; top: 0 inside the same box as the SVG.

path() measures from the element's own position. Put it at the box corner and the route matches the drawing.
path() measures from the element's own position. Put it at the box corner and the route matches the drawing.

Two more things surprise people at first. Adding offset-path moves the element right away, to the start of the path, even with no animation. And it does not change layout: like a transform, the element keeps its original space and nothing around it moves.

Animate offset-distance

offset-distance is a length along the path. 0% is the start, 100% is the end, and pixel values such as 50px work too. Any keyframes animation can drive it, so duration, delay, iteration count and timing function all work as usual.

  • Constant speed: use linear. With ease or ease-in-out the element slows down near the ends.
  • Back and forth: add alternate, as the first example does.
  • Open path: values below 0% stay at the start and values above 100% stay at the end.
  • Closed shape: a circle or a path ending in Z wraps around, so 125% lands at the same spot as 25%.

Turn with the curve: offset-rotate

By default the element turns so its right side points the way it is going. That is offset-rotate: auto. Drag the slider below, and switch the rotation and anchor to compare. The red dot marks the exact point on the path.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>offset-rotate and offset-anchor</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .stage { position: relative; width: 340px; height: 180px; margin: 0 auto; background: #fff; border-radius: 12px; }
  .stage svg { position: absolute; left: 0; top: 0; }
  .car, .pin {
    position: absolute; left: 0; top: 0;
    offset-path: path('M 30 140 C 100 -10, 200 200, 310 50');
  }
  .car {
    width: 56px; height: 26px; border-radius: 6px 14px 14px 6px;
    background: linear-gradient(90deg, #93c5fd, #2563eb); outline: 1px dashed #1d4ed8;
    font: 700 11px/26px system-ui, sans-serif; color: #fff; text-align: right; padding-right: 6px; box-sizing: border-box;
  }
  .pin { width: 8px; height: 8px; border-radius: 50%; background: #c2410c; offset-rotate: 0deg; } /* the exact point on the path */
  .playing .car, .playing .pin { animation: drive 4s linear infinite; }
  @keyframes drive { from { offset-distance: 0%; } to { offset-distance: 100%; } }
  form { max-width: 340px; margin: 12px auto 0; display: grid; grid-template-columns: auto 1fr; gap: 8px 10px; align-items: center; font-size: 13px; }
  select, input, button { font: inherit; }
  button { grid-column: 1 / -1; padding: 6px; border: 1px solid #c9cdd4; border-radius: 8px; background: #fff; cursor: pointer; }
  pre { max-width: 340px; margin: 10px auto 0; padding: 8px 10px; border-radius: 8px; background: #1d2330; color: #d6f2df; font-size: 12px; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="stage" id="stage">
  <svg width="340" height="180" aria-hidden="true">
    <path d="M 30 140 C 100 -10, 200 200, 310 50" fill="none" stroke="#c9cfd8" stroke-width="3" stroke-dasharray="6 6"/>
  </svg>
  <div class="car" id="car">GO</div>
  <div class="pin" id="pin"></div>
</div>

<form id="f">
  <label for="dist">offset-distance</label>
  <input type="range" id="dist" min="0" max="100" value="35">
  <label for="rot">offset-rotate</label>
  <select id="rot"><option>auto</option><option>0deg</option><option>reverse</option><option>auto 90deg</option><option>30deg</option></select>
  <label for="anc">offset-anchor</label>
  <select id="anc"><option>auto</option><option>left center</option><option>right center</option><option>left top</option></select>
  <button type="button" id="play">Play</button>
</form>
<pre id="code"></pre>

<script>
  const car = document.getElementById('car'), pin = document.getElementById('pin');
  const dist = document.getElementById('dist'), rot = document.getElementById('rot'), anc = document.getElementById('anc');
  const stage = document.getElementById('stage'), play = document.getElementById('play'), code = document.getElementById('code');

  function update() {
    const d = dist.value + '%';
    car.style.offsetDistance = d;
    pin.style.offsetDistance = d;             // the pin marks the point on the path
    car.style.offsetRotate = rot.value;
    car.style.offsetAnchor = anc.value;
    code.textContent = '.car {\n  offset-distance: ' + d + ';\n  offset-rotate: ' + rot.value +
      ';\n  offset-anchor: ' + anc.value + ';\n}';
  }
  document.getElementById('f').addEventListener('input', update);

  play.addEventListener('click', () => {
    const on = stage.classList.toggle('playing');  // the animation overrides the slider value
    dist.disabled = on;
    play.textContent = on ? 'Stop' : 'Play';
  });
  update();
</script>
</body>
</html>
The car and the red pin share the path. Change offset-rotate and offset-anchor and watch which part of the car rides the line.
Value What the element does
auto (default) Faces the direction of travel
0deg Never turns; stays upright
reverse Faces the direction of travel, turned around
auto 90deg Faces the direction of travel, plus a quarter turn
30deg Stays at a fixed 30 degree tilt

Use auto for arrows, cars and planes. Use 0deg for a dot, an icon or any text that must stay readable.

Which point sits on the line: offset-anchor

The path is a thin line and the element is a box. offset-anchor picks which point of the box is placed on the line. The default auto uses the element's transform-origin, which is the center unless you change the transform origin.

Top: the four common offset-rotate values. Bottom: offset-anchor moves the box around the red point.
Top: the four common offset-rotate values. Bottom: offset-anchor moves the box around the red point.

offset-anchor: right center makes the front edge lead, which suits a comet or a pointer. With the default center anchor, remember that half the element hangs past the path, so a path that starts at 0 0 pushes half the element outside the box.

ray(): a straight line at an angle

ray() is a straight line from a starting point. 0deg points up and angles turn clockwise, so 90deg points right. A size keyword sets the length: closest-side stops at the nearest edge of the box, and farthest-corner reaches the far corner.

.spark {
  offset-path: ray(45deg closest-side);
  offset-position: 50% 50%;   /* where the ray starts */
}

The start point is offset-position. Its default, normal, is the center of the containing block. auto starts from the element's own position, and a position such as left top or 20% 80% starts there.

You can also write the start inside the function: ray(45deg closest-side at 20% 80%).

Add contain inside ray() to shorten the line so the whole element stays in the box at 100%.

Basic shapes: circle(), ellipse(), inset(), polygon()

The shape functions from clip-path also work as routes. Their percentages are measured against the containing block, border included, so the route grows and shrinks with the box. That makes them the easy answer for motion that must fit a phone.

Where each route starts. A ray points out from offset-position; circles start at the right; boxes start at the top-left.
Where each route starts. A ray points out from offset-position; circles start at the right; boxes start at the top-left.
  • circle(60px at 50% 50%) starts at its rightmost point and runs clockwise.
  • ellipse(40% 38% at 50% 50%) does the same, stretched to the box.
  • inset(0) traces the box edge from the top-left corner, clockwise.
  • polygon() starts at its first point and follows the points in order.

A finished example: orbits, sparks and a comet

This scene uses all three kinds of route. Two planets orbit on circle() and ellipse(), eight sparks fly out on ray(), and a comet crosses on a path() with offset-anchor: right center so its bright head leads.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Orbits, sparks and a comet</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #0b1020; color: #e5e7eb; }
  .sky {
    position: relative; height: 260px; max-width: 560px; margin: 0 auto; overflow: hidden; border-radius: 14px;
    background: radial-gradient(circle at 50% 50%, #1e2a4a, #0b1020 70%);
  }
  .sun, .planet, .spark, .comet { position: absolute; left: 0; top: 0; }
  .sun {
    left: 50%; top: 50%; width: 44px; height: 44px; margin: -22px 0 0 -22px; border-radius: 50%;
    background: radial-gradient(circle, #fde68a, #f59e0b); box-shadow: 0 0 30px #f59e0b;
  }

  /* basic shapes: percentages follow the size of the sky */
  .planet { border-radius: 50%; offset-rotate: 0deg; animation: go linear infinite; }
  .p1 { width: 14px; height: 14px; background: #60a5fa; offset-path: circle(60px at 50% 50%); animation-duration: 5s; }
  .p2 { width: 20px; height: 20px; background: #34d399; offset-path: ellipse(40% 38% at 50% 50%); animation-duration: 11s; }

  /* ray(): straight out from offset-position (the center, by default) */
  .spark { width: 6px; height: 6px; border-radius: 50%; background: #fde68a; animation: burst 2.4s ease-out infinite; }

  /* path(): fixed pixel route; the head of the comet sits on it */
  .comet {
    width: 70px; height: 8px; border-radius: 4px;
    background: linear-gradient(90deg, transparent, #bae6fd 70%, #fff);
    offset-path: path('M -80 40 Q 200 -10 640 110');
    offset-anchor: right center;   /* the bright end is the point on the path */
    animation: go 4s ease-in 1s infinite;
  }
  @keyframes go { from { offset-distance: 0%; } to { offset-distance: 100%; } }
  @keyframes burst {
    from { offset-distance: 0%; opacity: 1; }
    to   { offset-distance: 45%; opacity: 0; }
  }
  .paused * { animation-play-state: paused; }
  button {
    display: block; margin: 10px auto 0; padding: 6px 18px; border-radius: 99px;
    border: 1px solid #334155; background: #111827; color: #e5e7eb; font: inherit; cursor: pointer;
  }
</style>
</head>
<body>
<div class="sky" id="sky">
  <div class="sun"></div>
  <div class="planet p1"></div>
  <div class="planet p2"></div>
  <div class="comet"></div>
</div>
<button type="button" id="toggle">Pause</button>

<script>
  const sky = document.getElementById('sky'), toggle = document.getElementById('toggle');

  // eight sparks, one ray each, 45 degrees apart
  for (let i = 0; i < 8; i++) {
    const s = document.createElement('div');
    s.className = 'spark';
    s.style.offsetPath = 'ray(' + i * 45 + 'deg closest-side)';
    sky.appendChild(s);
  }

  function setPaused(p) {
    sky.classList.toggle('paused', p);
    toggle.textContent = p ? 'Play' : 'Pause';
  }
  toggle.addEventListener('click', () => setPaused(!sky.classList.contains('paused')));
  // start paused for people who asked their system for less motion
  setPaused(matchMedia('(prefers-reduced-motion: reduce)').matches);
</script>
</body>
</html>
On a wider screen the ellipse orbit stretches with the box. The pixel comet path keeps its size.

It starts paused when the visitor's system asks for reduced motion, and the button pauses every animation at once with animation-play-state.

When it does not work

What you see Cause Fix
The element runs beside the drawn curve, not on it path() starts at the element's own position position: absolute; left: 0; top: 0 in the same box as the SVG
Half the element hangs off the box at the start The anchor is the center Move the path start inward, or set offset-anchor
The element turns sideways or upside down offset-rotate is auto offset-rotate: 0deg
A ray starts in the middle of the box offset-position is normal, the center Set offset-position, or add at inside ray()
The route is too big on a phone path() numbers are fixed pixels Use a basic shape with percentages
The speed changes along the route The timing function eases animation-timing-function: linear
Values past 100% stop at the end Open paths clamp the distance Close the path with Z or use a shape

Motion is hard to judge from a screenshot or a description. A still image freezes it, and an .html attachment may open as plain code on a phone.

To show the moving version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its animations and scripts run, so the people you send it to can watch the dot follow the curve and press the buttons themselves. If you change the path later, the same link shows the new version.

Questions people ask

What does offset-path do in CSS?

It gives an element a route to travel on: a path() with SVG path data, a ray() at an angle, or a basic shape such as circle() or inset(). offset-distance then places the element somewhere along that route, from 0% at the start to 100% at the end.

Can I reuse the d attribute of an SVG path?

Yes. path() takes the same path data string as the d attribute of an SVG <path>, for example path('M 20 170 C 70 20, 150 20, 170 100'). The numbers are CSS pixels, measured from the element's own top-left position.

How do I stop the element from rotating along the path?

Set offset-rotate: 0deg. The default, auto, turns the element so it faces the direction of travel. A fixed angle keeps it upright the whole way.

Does offset-path change the page layout?

No. Like transform, it only moves where the element is drawn. The element keeps its original space in the layout, and the content around it does not move.

Why does my motion path not fit on a phone?

The numbers inside path() are fixed pixels, so the route does not shrink with the screen. Use a basic shape with percentages, such as ellipse(40% 38% at 50% 50%), or keep the box that holds the path at a fixed size.

Keep reading