Animating an Element Along an Offset Path

Part of Motion Path & Individual Transforms in Core CSS Animation Fundamentals.

The problem

Curved motion used to mean one of two workarounds: sample the curve at author time and emit a keyframe every few percent, or drive the position from JavaScript on every frame. The first produces unreadable stylesheets that are impossible to adjust; the second puts a transform write on the main thread sixty times a second.

offset-path removes both. The curve is declared once as geometry, and a single animatable property — offset-distance — moves the element along it. The result is one keyframe pair, compositor-sampled, with the curve visible in the source as a path definition rather than as forty interpolated coordinates.

Root cause: position along a curve is one dimension, not two

The reason the old approaches were awkward is that they modelled a curve as two independent animations. translateX and translateY each interpolate linearly between keyframes, so a curve has to be approximated by enough straight segments that the corners stop showing — and each segment needs its own timing, or the element speeds up on the diagonals.

A motion path models it correctly: the curve is a one-dimensional space, and progress along it is a single scalar. The browser flattens the path once, builds a lookup from distance to position, and each frame reads a position for the current offset-distance. Speed along the curve is uniform by construction, and the easing applies to progress rather than to two axes independently.

Three companion properties complete the model. offset-rotate decides whether the element turns to follow the tangent. offset-anchor decides which point of the element’s box sits on the path — the centre by default, which is why an arrow authored with its tip at the top-left appears to trail behind the curve until the anchor is corrected. offset-position sets the origin for paths that need one.

What the browser does once, and what it does per frameThe expensive geometry work happens at style time; the animation itself is a lookup.What the browser does once, and what it does per frameoffset-pathparsedstyle, onceCurve flattenedonceDistance toposition mapcachedoffset-distancesampledper frameRotationappliedif autoMatrixcompositedGPU
The expensive geometry work happens at style time; the animation itself is a lookup.

Step-by-step resolution

Getting the element onto the curveMost motion-path problems are coordinate-system problems, so establish the box first.Getting the element onto the curve1Give the containing block explicit dimensions and position: relative.Path coordinates now mean what the SVG editor showed2Paste the path data into offset-path: path('...') and set offset-distance:0%.The element jumps to the path's start point3Set offset-anchor to the point of the element that should ride the curve.An arrow's tip, a marker's base, a dot's centre4Animate offset-distance to 100% and choose offset-rotate deliberately.Uniform speed along the whole route5Add the reduced-motion branch that places the element at 100% with noanimation.The destination is still communicated
Most motion-path problems are coordinate-system problems, so establish the box first.

Production code pattern

/* Intent: a delivery marker that traces a route as the section scrolls into
   view, facing its direction of travel, with the route authored once. */
.route {
  position: relative;
  width: 100%;
  max-width: 480px;
  aspect-ratio: 480 / 200;   /* the coordinate system the path was drawn in */
  margin-inline: auto;
}

.route__marker {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  width: 18px;
  height: 18px;

  /* The route. Coordinates resolve against .route, not against the marker. */
  offset-path: path('M 12 168 C 120 24, 300 232, 468 88');
  offset-distance: 0%;
  offset-rotate: auto;        /* nose follows the tangent */
  offset-anchor: center;      /* the marker's centre rides the curve */

  animation: travel linear both;
  animation-timeline: view();  /* progress comes from scroll position */
  animation-duration: auto;
}

@keyframes travel {
  to { offset-distance: 100%; }
}

/* Accessibility gate: no traversal, but the marker still shows the outcome. */
@media (prefers-reduced-motion: reduce) {
  .route__marker {
    animation: none;
    animation-timeline: auto;
    offset-distance: 100%;
    offset-rotate: 0deg;      /* upright at rest */
  }
}

Rendering Impact: offset-distance — composite. The path is flattened once at style time; the scroll timeline means no scroll listener runs, so the whole traversal costs one compositor sample per frame.

Two details in that block are the ones people miss. aspect-ratio on the container is what makes the path coordinates portable: the path was drawn in a 480 × 200 space, and the ratio keeps that space intact as the container scales. And offset-rotate: 0deg in the reduced-motion branch matters because auto leaves the marker rotated at whatever angle the curve ended on — a detail that only shows up when the animation is removed.

Path shapes and what each is good forpath() is the most precise; ray() and shapes scale with the box for free.Path shapes and what each is good forScales with the box?Best forpath('M ...')No — fixed coordinatesPrecise routes drawn in a designtoolray(45degclosest-side)YesRadial bursts and directional exitscircle() /ellipse()YesOrbits and dial indicatorsinset() withroundingYesTracing the edge of a cardurl(#svgPath)Depends on the SVGReusing a path already in thedocument
path() is the most precise; ray() and shapes scale with the box for free.

Verification checklist

Constraints and trade-offs

  • path() coordinates are absolute, so a route drawn for one container size needs an aspect-ratio or a rewrite when the box changes shape.
  • An element positioned by a motion path ignores its static position; layout siblings still reserve its original space unless it is taken out of flow.
  • offset-rotate: auto rotates everything inside the element, including text and focus rings.
  • The offset shorthand resets the longhands it omits, which quietly drops an offset-anchor set elsewhere.
  • Long, complex paths cost more to flatten at style time; the per-frame cost is unaffected, but a page with hundreds of them pays for it during the initial style pass.

Frequently asked questions

Where do offset-path coordinates start from?

From the origin of the element’s containing block, not from the element’s own position. An element with offset-path is positioned entirely by the path, so its static position becomes irrelevant — which is why a path authored against one container size lands somewhere unexpected in another.

Can the path itself be animated?

Between compatible shapes, yes, but it forces the geometry to be recomputed every frame and belongs in the same category as animating clip-path. Swapping paths at a state boundary while offset-distance holds still is almost always the better design.

How do I keep a label upright while it travels?

Set offset-rotate: 0deg. The default in most examples is auto, which turns the element to follow the tangent — fine for an arrow or a vehicle, unreadable for text and impossible for anyone using screen magnification.


Reusing a path the document already contains

offset-path: path() duplicates geometry that frequently already exists in an inline SVG on the page — the route the marker is following is usually drawn as a visible line. Keeping two copies of the same coordinates is a maintenance trap: someone adjusts the artwork and the marker quietly stops following it.

offset-path: url(#route) references a path element by id, so the artwork and the motion share one definition. Adjust the SVG and the marker follows.

Two constraints come with it. The referenced path must be in the same document — a path inside an external SVG file loaded through an <img> is not reachable — so the illustration has to be inlined, which it usually is on this kind of page anyway. And the coordinate system is still the animated element’s containing block, not the SVG’s viewBox, so the two only agree when the SVG is sized to match its own coordinates. Giving the wrapper the same aspect-ratio as the viewBox is what keeps them aligned as the layout scales.

Where the artwork and the motion genuinely need to diverge — a marker that cuts a corner the drawn route takes wide — keeping separate definitions is the right call. Make it a deliberate comment rather than an accident of copy-paste.