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.
Step-by-step resolution
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
--dialtransition, then composite. The badge orbit animatesoffset-distancedirectly 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.
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
rotatewith a matchingtransform-originis simpler. - A visual dial is decoration; the underlying
input type="range"orrole="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.
Related
- Motion Path & Individual Transforms — the parent topic
- offset-anchor and offset-position Explained — centres and anchor points for shapes
- Registering Custom Properties with @property — the typed value driving the dial