Motion Path Orbits and Dial Indicators

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

The problem

A settings page has a circular volume dial with a small knob that moves around its edge as the value changes. A profile avatar has an “online” badge that should sit at a particular angle on its border, and orbit once when a status changes. A dashboard gauge has a marker that sweeps along a 270-degree arc.

The usual approach rotates a wrapper element around the centre and counter-rotates the marker inside it so its label stays upright — two nested elements and two transforms for what is conceptually one point on a circle. Motion paths with circle() place the point directly.

Root cause analysis: a closed shape measured in percentages

offset-path: circle() turns the circle’s outline into a path. offset-distance then measures position along that outline: 0% is the start point and 100% is one full circuit back to it. Converting a value to a position is therefore a percentage of the circumference, with no trigonometry.

Two conventions decide where things land. Basic-shape paths start at the top of the shape and run clockwise. So offset-distance: 25% is the right-hand side, 50% the bottom and 75% the left. A dial whose zero is at the bottom-left, like most gauges, needs its value mapped into a range that starts at 62.5% and wraps around.

The circle’s radius and centre come from the shape: circle(46% at 50% 50%) in the containing block, where percentages resolve against the reference box. Omitting at makes the centre depend on offset-position. A radius in percent follows the container, so the same rule works for a 32px avatar badge and a 240px dial.

Wrap-around needs care. Transitioning from 95% to 5% goes the long way round — backwards through 90 percentage points — because the value is just a number. For a continuous dial, keep the unwrapped value (105%) and let the path wrap it; distances above 100% continue around a closed path.

A 270-degree gauge mapped onto a circle pathGauge zero sits at 62.5% (bottom-left); full scale at 137.5%, which wraps to 37.5%.A 270-degree gauge mapped onto a circle pathupper half of sweepdead zonelower-left start0255075100% distancevalue 0 62.5% distancevalue 50 12.5% distancevalue 100 37.5% distance
Gauge zero sits at 62.5% (bottom-left); full scale at 137.5%, which wraps to 37.5%.

Step-by-step resolution

A value-driven dial markerOne custom property carries the value; CSS does the geometry.A value-driven dial marker1Make the dial a square, positioned container.Percent radii resolve predictably2Give the marker offset-path: circle(44% at 50% 50%).The marker rides the dial rim3Register --dial as a <number> from 0 to 1.The value interpolates smoothly4Set offset-distance: calc(62.5% + var(--dial) * 75%).0 at bottom-left, 1 at bottom-right5Transition --dial and update it from the input's value.The knob glides to new values6Keep offset-rotate: 0deg for the knob's label.
One custom property carries the value; CSS does the geometry.

Production code pattern

@property --dial {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

.dial {
  position: relative;
  inline-size: 12rem;
  aspect-ratio: 1;
  border-radius: 50%;
  --dial: 0.4;
  transition: --dial 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

.dial__knob {
  position: absolute;
  inset: 0 auto auto 0;
  inline-size: 1.25rem;
  aspect-ratio: 1;
  offset-path: circle(44% at 50% 50%);
  offset-distance: calc(62.5% + var(--dial) * 75%);  /* 270-degree sweep from bottom-left */
  offset-anchor: center;
  offset-rotate: 0deg;
}

/* An avatar status badge parked at 45 degrees, orbiting once on status change. */
.avatar__status {
  position: absolute;
  inset: 0 auto auto 0;
  offset-path: circle(50% at 50% 50%);
  offset-distance: 12.5%;                            /* top-right */
}
.avatar__status.is-changed {
  animation: orbit-once 700ms cubic-bezier(0.45, 0, 0.25, 1);
}
@keyframes orbit-once {
  from { offset-distance: 12.5%; }
  to   { offset-distance: 112.5%; }                  /* one full circuit, same end point */
}

@media (prefers-reduced-motion: reduce) {
  .dial { transition: none; }
  .avatar__status.is-changed { animation: none; }
}
const dial = document.querySelector('.dial');
const input = dial.querySelector('input[type="range"]');
input.addEventListener('input', () => {
  dial.style.setProperty('--dial', String(input.valueAsNumber / 100));
});

Rendering Impact: main-thread style for the registered --dial transition, then composite. The badge orbit animates offset-distance directly and can be sampled like a transform; the dial goes through a custom property, which costs a style recalculation per frame — negligible for one knob.

The orbit keyframes end at 112.5%, which lands on the same point as 12.5%, so the badge completes a full circuit and settles where it started without a jump when the animation is removed.

Nested rotation against a circle path for the same knobBoth are compositor-friendly; the path version has less to keep in sync.Nested rotation against a circle path for the same knobRotating wrapper + counter-rotationWrapper: rotate(var(--angle))Knob: rotate(calc(-1 * var(--angle)))Two elements, two transformsWorks, more moving partsoffset-path: circle()Knob: offset-distance from the valueoffset-rotate: 0deg keeps it uprightOne element, no counter-rotationSimpler geometry
Both are compositor-friendly; the path version has less to keep in sync.

Verification checklist

Constraints and trade-offs

  • Circle paths always start at the top and run clockwise; a counter-clockwise dial needs negative multipliers.
  • Transitions between percentages take the numeric route, which can be the long way round a closed path.
  • circle() with percentage radii resolves against the reference box, so non-square containers give ellipse-like expectations but circle results.
  • For a single needle rotating around its base, a plain rotate with a matching transform-origin is simpler.
  • A visual dial is decoration; the underlying input type="range" or role="slider" must carry the value.

Frequently asked questions

Where does a circle() motion path start?

At the top of the circle, and it runs clockwise. 25% is the right side, 50% the bottom and 75% the left.

How do I make an element orbit exactly once and stop where it started?

Animate offset-distance from its resting value to the same value plus 100%. On a closed path those two distances are the same point.

Is offset-path better than rotate for a gauge needle?

Not for a needle that pivots on the centre; rotate with transform-origin at the base is simpler. offset-path is better for markers on the rim that must stay upright.