CSS offset-path: Animating Elements Along Custom Paths
Learn how CSS offset-path lets you animate elements along SVG and geometric paths - with real code examples, browser gotchas, and practical motion design tips.
What offset-path Actually Does
The CSS Motion Path spec - officially part of CSS Animations Level 2 - lets you move any element along an arbitrary path instead of the usual straight-line interpolation you get from translateX/translateY. Before 2016, doing this in pure CSS was impossible. You'd reach for JavaScript, GSAP, or a canvas hack. Now it's four lines of CSS.
The core property is offset-path. Give it an SVG path string, a circle(), ray(), or url() reference to an inline SVG <path>, and the browser figures out all the bezier math for you. Pair it with offset-distance animated from 0% to 100% inside a @keyframes block, and you've got an element that physically rides the curve.
Worth noting: the full property family is offset-path, offset-distance, offset-rotate, and offset-anchor. You usually only need the first two to get something working, but offset-rotate: auto is the one that makes an arrow or car icon actually point in the direction of travel - and forgetting it is the #1 reason motion path animations look wrong.
Honestly, once you've used this for a decorative loader or a nav tooltip pointer, you'll feel a bit embarrassed about how many JS animation libraries you've pulled in for the same job.
Browser Support in 2026 (Spoiler: It's Good)
As of mid-2026, offset-path has green lights in Chrome 55+, Edge 79+, Firefox 72+, and Safari 15.4+. That covers 96%+ of global browser share, so you can ship it without a polyfill in most production contexts. The one real gap is older Samsung Internet builds on Android 9 devices - worth a quick check in your analytics.
The path() value inside offset-path - which takes an SVG path string directly - had slightly later support. Firefox didn't ship it until version 72, and Safari held out until 15.4. If you need to support Safari 14, swap path('M 0 0 Q 150 -80 300 0') for url('#myPath') referencing an inline SVG, which has broader support going back to Safari 12.
Quick aside: Chrome DevTools has a dedicated Motion Path inspector since Chrome 106. Click an element using offset-path, open the Styles panel, and you'll see a path visualizer. It's genuinely useful for debugging weird curve shapes.
Your First Motion Path Animation
Let's start simple. You want a dot to orbit a circle. No SVG, just a circle() shape function:
.dot {
width: 16px;
height: 16px;
background: #a855f7;
border-radius: 50%;
offset-path: circle(80px at center);
animation: orbit 2s linear infinite;
}
@keyframes orbit {
from { offset-distance: 0%; }
to { offset-distance: 100%; }
}That's it. The dot travels a 160px diameter circle - 80px radius - centred on its own offset-anchor. No trigonometry, no requestAnimationFrame.
Now step it up with a real bezier path. Copy any path from Figma or Inkscape, paste it into path():
.rocket {
width: 32px;
height: 32px;
offset-path: path('M 20 200 C 80 20, 200 20, 260 200 S 380 380, 440 200');
offset-rotate: auto;
animation: fly 3s ease-in-out forwards;
}
@keyframes fly {
from { offset-distance: 0%; }
to { offset-distance: 100%; }
}The offset-rotate: auto is what rotates the rocket to follow the curve tangent at each point. Without it you'd see a rocket pointing right the whole time, rotating its nose through the ground on the downslope. Always set it when your element has a meaningful orientation.
One more thing - offset-anchor defaults to 50% 50%, meaning the element's centre rides the path. If you're animating a teardrop or arrow that should pierce the path with its tip, shift the anchor: offset-anchor: 50% 0% puts the top-centre on the path instead.
Using SVG Path References for Complex Shapes
The path() string approach works for moderate complexity, but copy-pasting a 400-character path string into your CSS is a maintenance nightmare. The url() approach keeps your path in the DOM where it belongs:
<svg style="position:absolute;width:0;height:0;overflow:hidden">
<defs>
<path id="wavePath"
d="M 0 100 Q 150 0 300 100 T 600 100" />
</defs>
</svg>
<div class="token"></div>.token {
width: 24px;
height: 24px;
background: #f59e0b;
offset-path: url(#wavePath);
offset-rotate: auto;
animation: travel 4s ease-in-out infinite alternate;
}
@keyframes travel {
from { offset-distance: 0%; }
to { offset-distance: 100%; }
}The hidden <svg> trick - zero dimensions, absolute positioning, overflow hidden - lets you stash path definitions without occupying layout space. It's the same pattern used for SVG sprite sheets and it works across all browsers that support url() references.
In practice, if you're working in React, this is a good candidate for a single shared SVG definition rendered once at the root layout level, then referenced by class or id from any component tree. No need to inline paths in every component that uses motion. This pattern scales well when you start having four or five animated elements following the same track - think a loading train, a progress indicator, or decorative particles.
That said, be mindful of coordinate systems. The SVG path is defined in user-unit space, but your element sits in the normal CSS layout flow. The element moves relative to its containing block, with path coordinates mapping to CSS pixels 1:1. If you size the path to 600×200 user units, expect it to take up roughly 600×200px on screen. You might want to wrap both the SVG defs and the animated elements in a position:relative container sized to match.
Timing, Easing, and the Staggered Particle Trick
Motion path animations respond to all the normal animation sub-properties - animation-timing-function controls how offset-distance progresses over time. ease-in-out on a curved path reads as natural deceleration through bends; linear reads as mechanical. Neither is universally right - it depends on whether you're doing UI micro-interactions or decorative motion.
The timing function applies to the *distance* along the path, not the XY position directly. This is subtle but important. On a tight curve at 50% of path length, ease-in-out means the element physically slows as it rounds the bend. That's usually what you want for interactive elements. For orbiting particles, linear keeps consistent angular velocity.
The staggered particle effect you see on landing pages - a cluster of dots flowing along the same path at different phases - is embarrassingly simple:
.particle { offset-path: path('M 0 50 Q 200 0 400 50 T 800 50'); }
.p1 { animation: flow 3s linear infinite; }
.p2 { animation: flow 3s linear infinite; animation-delay: -1s; }
.p3 { animation: flow 3s linear infinite; animation-delay: -2s; }
@keyframes flow {
from { offset-distance: 0%; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
to { offset-distance: 100%; opacity: 0; }
}Negative animation-delay starts each particle already mid-journey on page load. Add a handful of these with slightly varied sizes and you get a flowing particle trail with zero JavaScript. Pair this with the kind of vivid gradients you'd build in the gradient generator and you've got a solid hero section background effect.
Motion Path in UI Components: Practical Patterns
Decorative particles are fun, but motion path earns its keep in functional UI too. Notification badges that fly to a cart icon. Tutorial spotlight indicators that trace around the UI in a guided tour. Custom loading spinners that follow non-circular paths. These all feel premium compared to the same thing done with keyframed transform: translate() chains.
For a cart flyout animation - where a product thumbnail sails from the product card to the cart icon - you calculate the SVG path at runtime based on the start and end DOM positions, then inject it into a <path> definition and trigger the animation. Something like this:
function animateToCart(fromEl, toEl) {
const from = fromEl.getBoundingClientRect();
const to = toEl.getBoundingClientRect();
const dx = to.x - from.x;
const dy = to.y - from.y;
const cpX = dx * 0.5;
const cpY = Math.min(from.y, to.y) - 120; // arc up 120px
const d = `M 0 0 Q ${cpX} ${cpY - from.y} ${dx} ${dy}`;
// inject `d` into a hidden SVG path, apply offset-path: url(#flyPath)
}You'd layer this with a CSS @keyframes that fades and scales the clone element, and the whole thing comes across as a polished, app-level transition. No GSAP licence needed.
Look, if you're building component-heavy interfaces with Empire UI, motion path can make your transitions feel custom without shipping a full animation library. The CSS spec handles the path math; you just pick the coordinates. That's the deal.
One more thing - combine offset-path with @media (prefers-reduced-motion: reduce) to disable or simplify path animation for users who've opted out. Respect that preference. A solid UI component ships with reduced-motion handling built in, not bolted on.
Common Gotchas and How to Fix Them
The element jumps to 0,0 before animating. This happens when offset-distance isn't set on the element before the animation starts. Add offset-distance: 0% to the base element styles - not just inside @keyframes. The browser needs an initial value to interpolate from.
The path coordinates are wrong / element flies offscreen. SVG user-unit coordinates are relative to the SVG viewport, not the page. If you're using url(#pathId) and the path was designed in a 1000×500 SVG canvas but your element lives in a 400px container, everything will be scaled wrong. Either design your paths in the coordinate space that matches your layout, or use path() strings which map 1:1 to CSS pixels.
`offset-rotate: auto` isn't rotating the element. Check that your element actually has a visual direction. A square or circle looks the same from all angles - you only notice rotation with an arrow, icon, or asymmetric shape. Also double-check that transform-origin isn't interfering; if you've set a custom transform on the same element, some browsers have historically had compositing quirks here (largely fixed in Chrome 108+ but worth testing).
Safari paints the element at the wrong z position. Safari sometimes composites motion-path elements above or below their expected stacking context. Wrapping the animated element in a position:relative; z-index:0 parent usually sorts it. This was a known Webkit bug present through Safari 16 and is patched in 17+.
For UI patterns that involve layered surfaces - think an element animated over a glassmorphism card - make sure your backdrop-filter containers don't inadvertently create a new stacking context that traps the motion-path element beneath them. Debug with DevTools Layers panel if you see clipping.
FAQ
Yes, but carefully. You can stack transform on top of motion path - the browser applies offset-path positioning first, then transforms. Just don't use transform: translate() to fake position adjustments when offset-anchor is the cleaner fix.
Animate offset-distance from 100% to 0%, or use animation-direction: reverse. Both work. The reverse approach is cleaner if you want a ping-pong with alternate.
Yes - like transform and opacity, it runs on the compositor thread and doesn't trigger layout. You can animate dozens of elements along paths without a jank problem on modern hardware.
GSAP has its own MotionPath plugin that does the same thing with more JS control. Framer Motion doesn't natively support CSS offset-path but you can set it via style prop and animate offsetDistance with a useMotionValue. Native CSS is simpler for static paths.
