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.
Step-by-step resolution
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.
Verification checklist
Constraints and trade-offs
path()coordinates are absolute, so a route drawn for one container size needs anaspect-ratioor 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: autorotates everything inside the element, including text and focus rings.- The
offsetshorthand resets the longhands it omits, which quietly drops anoffset-anchorset 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.
Related
- CSS Motion Path & Individual Transform Properties — the parent guide, including how the offset family composes with
transform - Scroll-Driven Animation Patterns — the timeline used to advance the traversal with scroll
- Vestibular-Safe Motion Patterns — distance and velocity limits that apply to curved travel