Animating Along a Ray with offset-path
Part of Motion Path & Individual Transforms in Core CSS Animation Fundamentals.
The problem
A floating action button opens into a fan of five smaller actions that burst outward at different angles. A “like” button emits a ring of particles. A radial menu places items around a centre at equal angles. The classic implementation computes cos and sin for each item in a preprocessor or script and writes a translate() per element — and the numbers have to be recomputed whenever the radius, the item count or the container size changes.
offset-path: ray() expresses exactly this geometry: a straight line from an origin at a given angle. Each element needs only its angle; the distance is a percentage along the ray, and the ray’s length can follow the container.
Root cause analysis: a ray is an angle plus a length rule
ray() takes three parts. The angle is measured clockwise from the top, like a compass bearing: 0deg points up, 90deg points right. The size decides how long the ray is — the value that offset-distance: 100% corresponds to. And the optional contain flag shortens the ray so the whole element box stays inside the containing block at 100%, rather than only the anchor point.
The size keywords are all measured from the ray’s origin to the edges of the containing block: closest-side to the nearest edge, farthest-side to the furthest edge, closest-corner and farthest-corner to the corners, and sides to the point where the ray itself crosses the box’s edge. sides is the one that makes each ray end exactly on the container’s border regardless of angle, which is what edge-to-edge bursts want.
The origin comes from offset-position. With its default, the ray starts from the element’s own position in layout; setting a position such as offset-position: 50% 50% starts every ray from the centre of the containing block. For a fan-out menu, that means all items can share the same layout position and differ only by angle.
Because the ray’s length is computed from the containing block, rays scale with the box. Enlarge the menu container and every item travels further without any value changing — the property that fixed path() coordinates lack.
Step-by-step resolution
Production code pattern
.fan {
position: relative;
inline-size: 16rem;
block-size: 9rem;
}
.fan__item {
position: absolute;
inset-inline-start: calc(50% - 1.5rem);
inset-block-end: 0;
offset-position: 50% 100%; /* every ray starts bottom-centre */
offset-path: ray(var(--angle) closest-side contain);
offset-rotate: 0deg; /* icons and labels stay upright */
offset-distance: 0%;
opacity: 0;
transition:
offset-distance 380ms cubic-bezier(0.34, 1.4, 0.64, 1) calc(var(--i) * 30ms),
opacity 160ms linear calc(var(--i) * 30ms);
}
.fan__item:nth-child(1) { --angle: -60deg; --i: 0; }
.fan__item:nth-child(2) { --angle: -30deg; --i: 1; }
.fan__item:nth-child(3) { --angle: 0deg; --i: 2; }
.fan__item:nth-child(4) { --angle: 30deg; --i: 3; }
.fan__item:nth-child(5) { --angle: 60deg; --i: 4; }
.fan[data-open] .fan__item {
offset-distance: 100%;
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.fan__item {
offset-distance: 100%; /* already in place */
transition: opacity 120ms linear; /* appear, do not travel */
}
}
Rendering Impact: composite. Moving an element along its offset path is folded into the element’s transform, so sampling
offset-distancebehaves like a transform animation; the ray length is resolved at style time from the containing block.
The overshooting easing — a control point above 1 — makes items travel slightly past their final position and settle back, which reads as a burst. With contain, the overshoot can push the element beyond the box for a few frames, so leave a little padding in the container if it clips.
Radial particles from a click point
Rays also suit short-lived particles. Instead of a fixed offset-position, set it from the pointer coordinates in script, writing pixel values to the particle container’s style, and give each particle an angle around the full circle. Use ray(var(--angle) farthest-corner) with a small target distance such as offset-distance: 20% so particles fly a proportionate distance whatever the container size, then remove the elements on transitionend. Keep the count low; each particle is a separate compositor animation.
Verification checklist
Constraints and trade-offs
- Older engines without
ray()support leave items at their layout position; make that position usable. offset-positionsupport arrived later thanoffset-path; test the origin in each target browser.containshortens rays differently for elements of different sizes, so mixed-size items stop at different radii.- Very large overshoot values look playful but can clip in tight containers.
- A radial menu needs keyboard order and focus management independent of the visual angles.
Frequently asked questions
Which direction is 0deg in ray()?
Straight up. Angles increase clockwise, so 90deg points right and 180deg points down, the same convention as CSS gradients’ to-top orientation.
What does the contain keyword do?
It shortens the ray so that the whole element box, not just its anchor point, fits inside the containing block when offset-distance is 100%.
How is ray() different from translate() with trigonometry?
The geometry is the same, but ray() expresses it as an angle and a length rule, so distances follow the container size automatically and no sine or cosine values need recalculating.
Related
- Motion Path & Individual Transforms — the parent topic
- Animating Along an Offset Path — path() routes and scroll-linked travel
- offset-anchor and offset-position Explained — controlling which point rides the ray and where it starts