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.

ray() size keywords and what 100% reachessides ends every ray on the container edge; closest-side gives equal lengths in all directions.ray() size keywords and what 100% reaches100% reachesEqual length at all angles?closest-sideNearest edge from the originYesfarthest-sideFurthest edge from the originYesclosest-cornerNearest cornerYesfarthest-cornerFurthest cornerYessidesWhere this ray exits the boxNo, varies by angle
sides ends every ray on the container edge; closest-side gives equal lengths in all directions.

Step-by-step resolution

Building a fan-out action menuOne angle per item; everything else is shared.Building a fan-out action menu1Make the menu a positioned, fixed-size box so rays have a containing block.Ray lengths are predictable2Set offset-position: 50% 100% on every item to start rays at the bottom centre.All items share one origin3Give each item --angle from -60deg to 60deg in equal steps.A symmetrical fan4Set offset-path: ray(var(--angle) closest-side contain).Items stop inside the box5Transition offset-distance from 0% to 100% with a staggered delay.Items spring outward in sequence6Keep offset-rotate: 0deg so icons stay upright.
One angle per item; everything else is shared.

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-distance behaves 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.

How far item 3 travels as the container growsNo value in the CSS changes; closest-side follows the box.How far item 3 travels as the container growsContainer 12rem by 6rem72 pxContainer 16rem by 9rem120 pxContainer 20rem by 12rem168 px
No value in the CSS changes; closest-side follows the box.

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-position support arrived later than offset-path; test the origin in each target browser.
  • contain shortens 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.