GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Radial Menu: 9 Circular Control Patterns

Nine css radial menu style controls: one decays a dragged angle with inertia, one turns a value into a conic-gradient ring gauge, one snaps to the nearest

Auto-plays · click a tile to jump to its section · all nine in one zip

Every demo below waits for a real interaction before anything permanent happens: a pointerdown, a click, or an arrow key flips isDemo to false through the same human() guard, and the automatic preview stops for good. Four of the nine — 02, 04, 05, and 08 — call setPointerCapture so a drag keeps tracking even after the pointer leaves the handle, and 09 is driven by the arrow keys, Home, and End alone, with aria-valuenow kept in sync on every change. None of the nine calls Math.random anywhere; every loop step is a fixed formula over elapsed time or a stored angle. All nine read prefers-reduced-motion, but only four of them — 04, 05, 06, and 07 — branch on it again after a real interaction; the other five rest at a fixed preview position only inside the automatic loop, and hand reduced motion for anything a visitor actually does over to a media query in the SCSS.

Anatomy of nine patterns — which calculation moves the control

The table holds three measured values per item: how much of the frame the moving control filled cumulatively, how strong the per-pixel change was on average, and in how many of 23 sampled frames the pixels actually moved.

Item Cumulative area Avg intensity Moving frames
01 11.25% 98.5 12/23
02 23.78% 120.2 23/23
03 11.20% 75.6 22/23
04 17.07% 55.4 17/23
05 1.66% 153.9 18/23
06 5.93% 181.2 23/23
07 18.51% 54.2 12/23
08 8.37% 88.8 23/23
09 2.96% 150.8 15/23

The widest cumulative area belongs to item 02 alone at 23.78%, because spinning the whole genre disc repaints nearly every slot's size and glow each frame as it enlarges whichever label crosses the center. The narrowest is item 05 at 1.66%, since only the thin handle and a short arc redraw while the dial itself stays still between drags. Item 06 crosses the highest intensity in the set at 181.2 — the conic-gradient ring repaints in a hard-edged step every time GAUGE_STEP_DEG rounds to a new angle, unlike a smooth fade — while item 07's 54.2 is the lowest, its spring settling changing the handle's scale by only a few percent per frame. The most moving frames sit with items 02, 06, and 08 at 23 of 23, the fewest with items 01 and 07 at 12 of 23, and that gap mirrors how often each pattern actually has something new to draw rather than resting between steps.

Grouped by what math actually spins the handle, the nine split into four families.

01 — a fixed layout computed once, not a live pointer

Item 01 never reads a pointer position at all: each item's placement is a one-time polar-to-Cartesian conversion — an angle from its index over ARC_SPAN_DEG, then Math.cos and Math.sin times RADIUS — computed once when the menu builds and left untouched by opening or closing.

02 · 03 · 08 — an angle read live from the pointer

Item 02 turns a drag into a rotation value and finds the active slot with a modulo of STEP_DEG; item 03 converts the pointer position to a bearing with atan2 and floors it into one of N segments; item 08 also uses atan2, but clamps the result to a half circle and turns that same 0–1 position into both a coordinate and a hue.

04 · 05 · 07 — physics that keeps moving after release

Item 04 keeps spinning on a velocity that shrinks by DRAG_DECAY every frame until it drops under MIN_VELOCITY; item 05 compares the release angle against the nearest notch and, inside SNAP_THRESHOLD_DEG, eases past it and back with an overshoot curve; item 07 drives a scale value with a spring — force toward the target times SPRING_K, damped by SPRING_DAMP — so the handle presses in and rebounds instead of snapping straight to size.

06 · 09 — a value quantized straight into an angle

Item 06 turns a percentage into degrees and rounds down to the nearest GAUGE_STEP_DEG, filling the ring in visible steps rather than a smooth sweep; item 09 turns a percentage into degrees across RANGE_DEG and moves it only in fixed STEP_DEG increments from the arrow keys, with aria-valuenow announcing the exact number at every step.

Code per pattern

01Radial menu — a circular menu fanning out from a center point

Every item's spot is fixed by a single calculation at build time: the angle spreads item i evenly across ARC_SPAN_DEG (200) centered above the fab, then Math.cos and Math.sin at that angle times RADIUS (96) place it in pixels through the --x and --y custom properties. The fab's click toggles an rm--open class and each item's tabIndex between 0 and -1, and Escape closes the menu the same way a second click does.

What the numbers say

Item 01 sits at the low end of the nine for moving frames, 12 of 23, with an 11.25% cumulative area and 98.5 average intensity — the layout math runs once, so what actually repaints is only the open/close stagger, not a per-frame recalculation.

Radial menu — a circular menu fanning out from a center point
Open in a new tab

01 · Radial menu — a circular menu fanning out from a center point — Measured area 11.25% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

ARC_SPAN_DEGRADIUStranslate
  // Calculation layer: polar to Cartesian. Item i's slot sits centered on the top (-90deg), evenly split across ARC_SPAN_DEG
  // at that angle, RADIUS px out from the center. This coordinate is computed once, regardless of open state.
  var items = ACTIONS.map(function (label, i) {
    var li = document.createElement('li');
    li.className = 'rm__item';
    li.setAttribute('role', 'none');
    var btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'rm__itemBtn';
    btn.setAttribute('role', 'menuitem');
    btn.tabIndex = -1;
    btn.textContent = label;
    var angleDeg = -90 - ARC_SPAN_DEG / 2 + i * (ARC_SPAN_DEG / (ACTIONS.length - 1));
    var rad = angleDeg * Math.PI / 180;
    var x = Math.round(Math.cos(rad) * RADIUS);
    var y = Math.round(Math.sin(rad) * RADIUS);
    li.style.setProperty('--x', x);
    li.style.setProperty('--y', y);
    li.style.setProperty('--stagger', i);

02Genre wheel — spinning a disc of categories to pick one

Dragging rotates the whole disc by the pointer's angle delta, and activeIndex() finds which slot last crossed the center by taking the negated rotation modulo 360 and dividing it by STEP_DEG. Every apply() call updates the picked label to the active genre's name, whether a drag or the automatic loop moved the disc.

What the numbers say

Item 02 has both the widest cumulative area in the set, 23.78%, and ties for the most moving frames at 23 of 23, since spinning the disc constantly changes which slot is enlarged and repaints the ring's rotation every frame.

Genre wheel — spinning a disc of categories to pick one
Open in a new tab

02 · Genre wheel — spinning a disc of categories to pick one — Measured area 23.781% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

activeIndexSTEP_DEGhsl(
  // Calculation layer: the rotation angle modulo STEP_DEG gives "the index currently passing the center."
  function activeIndex() {
    var norm = ((-rotation % 360) + 360) % 360;
    return Math.round(norm / STEP_DEG) % N;
  }

  function apply() {
    ring.style.setProperty('--rotation', rotation);
    var idx = activeIndex();
    lis.forEach(function (li, i) { li.classList.toggle('gw__slot--active', i === idx); });
    picked.textContent = 'Selected: ' + GENRES[idx];
  }

03Segment-hover ring — only the arc under the pointer stands out

angleAt() converts the pointer position to a bearing with atan2, shifted so 12 o'clock reads as zero, and segmentIndexAt() floors that angle by 360/N to find which of the N segments the pointer sits over. Only that one segment's thickness and brightness change through a single active class, and moving the pointer away clears it.

What the numbers say

Item 03 ties near the top for moving frames at 22 of 23 with a 75.6 average intensity and an 11.20% cumulative area — nearly the same footprint as item 01 despite tracking the pointer continuously rather than computing once.

Segment-hover ring — only the arc under the pointer stands out
Open in a new tab

03 · Segment-hover ring — only the arc under the pointer stands out — Measured area 11.2% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

segmentIndexAtMath.atan2conic-gradient(
  // Calculation layer: convert the pointer position to an angle from the ring's center (atan2), then find which segment (360/N wide) it falls in.
  function angleAt(clientX, clientY) {
    var r = ring.getBoundingClientRect();
    var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
    var deg = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI + 90; // 12 o'clock reads as 0 degrees
    return ((deg % 360) + 360) % 360;
  }
  function segmentIndexAt(deg) {
    return Math.floor(deg / (360 / N)) % N;
  }

04Drag-inertia knob — a rotating handle that eases to a stop after release

Releasing the knob does not stop it: decayStep() multiplies the last velocity by DRAG_DECAY every frame and keeps adding it to the angle until the speed falls under MIN_VELOCITY, so the handle keeps turning on its own momentum. Under reduced motion the same function snaps velocity to zero immediately, skipping the coast entirely even after a visitor has taken over.

What the numbers say

Item 04 shows the second-widest cumulative area at 17.07% with a middling 55.4 intensity and 17 of 23 moving frames — the decay curve keeps repainting for several frames after release instead of resting right away.

Drag-inertia knob — a rotating handle that eases to a stop after release
Open in a new tab

04 · Drag-inertia knob — a rotating handle that eases to a stop after release — Measured area 17.067% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

decayStepDRAG_DECAYMath.atan2
  // Calculation layer: inertial decay. The velocity at release shrinks by DRAG_DECAY every frame while it keeps adding onto the angle.
  // Under reduce (including after a real interaction), the decay stretch is skipped and motion stops at once — this branch still applies even after human().
  function decayStep() {
    if (dragging) return;
    if (reduce) { velocity = 0; return; }
    if (Math.abs(velocity) < MIN_VELOCITY) { velocity = 0; return; }
    velocity *= DRAG_DECAY;
    angle += velocity;
  }

05Snap-select dial — settling onto the nearest notch with a small snap

On release, nearestNotch() measures the shortest angular distance between the drop angle and each of the N notches, and if that distance is within SNAP_THRESHOLD_DEG the dial eases toward the notch with an overshoot curve that passes it slightly before settling back. The same easeOvershoot() curve also drives the automatic demo loop's drag-then-settle cycle.

What the numbers say

Item 05 has the second-highest average intensity in the set, 153.9, with the smallest cumulative area of the nine at 1.66% and 18 of 23 moving frames — the handle itself makes a sharp, high-contrast move but only across a small patch of the dial.

Snap-select dial — settling onto the nearest notch with a small snap
Open in a new tab

05 · Snap-select dial — settling onto the nearest notch with a small snap — Measured area 1.661% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

nearestNotchSNAP_THRESHOLD_DEGNOTCH_DEG
  // Calculation layer: find the shortest angular distance between the release angle and the nearest notch (N-way split), and snap to it if that distance is inside the threshold.
  function nearestNotch(deg) {
    var idx = Math.round(deg / NOTCH_DEG) % N;
    var notchDeg = idx * NOTCH_DEG;
    var dist = Math.abs(((deg - notchDeg + 540) % 360) - 180);
    return { idx: idx, notchDeg: notchDeg, dist: dist };
  }

  function apply(a) {
    ring.style.setProperty('--angle', a);
  }

  function startSettle(target) {
    settling = true;
    settleFrom = angle;
    settleTo = target;
    settleStart = performance.now();
  }

  function easeOvershoot(t) {
    // A bounce that overshoots the target slightly before returning (easeOutBack style) — a sign-flipping curve cubic-bezier can't produce
    var c1 = 1.70158, c3 = c1 + 1;
    var t1 = t - 1;
    return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1;
  }

06Circular gauge bar — an angle that fills in proportion to a value

quantizedDeg() turns the value into degrees and floors it to the nearest GAUGE_STEP_DEG, so the conic-gradient ring fills in visible steps rather than a smooth sweep — a separate 0.16 easing factor on the displayed value controls how quickly it approaches the target, entirely independent of that stepping unit. Two buttons nudge the target value by STEP_PCT (10) in either direction, and the automatic loop fills the ring from 0 to 100 before starting over.

What the numbers say

Item 06 has both the highest average intensity in the set, 181.2, and ties for the most moving frames at 23 of 23, since every hard-edged step of the fill repaints a fresh wedge of the ring rather than blending into the last one.

Circular gauge bar — an angle that fills in proportion to a value
Open in a new tab

06 · Circular gauge bar — an angle that fills in proportion to a value — Measured area 5.931% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

quantizedDegGAUGE_STEP_DEGconic-gradient(
  // Calculation layer: convert the percentage to degrees, then floor it down to a multiple of GAUGE_STEP_DEG.
  // Shown stepped instead of a smooth 360*value/100, making this a "stepped" gauge.
  function quantizedDeg(v) {
    var raw = Math.min(100, Math.max(0, v)) * 3.6;
    return Math.floor(raw / GAUGE_STEP_DEG) * GAUGE_STEP_DEG;
  }

  function apply() {
    ring.style.setProperty('--deg', quantizedDeg(shown));
    pctEl.textContent = Math.round(shown) + '%';
  }

  function setValue(v) { value = Math.min(100, Math.max(0, v)); }

07Physical-feel knob — a dial handle that presses and rebounds with weight

springStep() pushes a scale value toward its target by a force equal to the distance times SPRING_K, then damps the accumulated velocity by SPRING_DAMP every frame, so the handle overshoots its rest size and rebounds instead of snapping straight to it. Pressing sets the target below 1 and releasing sets it above 1 before the spring settles back to a scale of exactly 1.

What the numbers say

Item 07 has the lowest average intensity in the set at 54.2 despite an 18.51% cumulative area, the widest outside items 02 and 04, because the spring only changes the handle's scale by a few percent per frame even while it keeps recalculating for 12 of 23 sampled frames.

Physical-feel knob — a dial handle that presses and rebounds with weight
Open in a new tab

07 · Physical-feel knob — a dial handle that presses and rebounds with weight — Measured area 18.514% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

springStepSPRING_KSPRING_DAMP
  // Calculation layer: a damped spring. Every frame, the distance to the target is multiplied by SPRING_K to accelerate,
  // and the accumulated velocity is multiplied by SPRING_DAMP to settle gradually (unlike a plain lerp, it overshoots the target and rebounds).
  function springStep() {
    if (reduce) { scaleCur = scaleTarget; scaleVel = 0; return; }
    var force = (scaleTarget - scaleCur) * SPRING_K;
    scaleVel = (scaleVel + force) * SPRING_DAMP;
    scaleCur += scaleVel;
  }

  function apply() {
    knob.style.setProperty('--scale', scaleCur.toFixed(4));
    valueEl.textContent = 'Brightness ' + LEVELS[levelIdx] + '%';
  }

08Arc mood slider — a handle traveling a half-circle to set intensity

angleToT() converts the pointer position to a bearing with atan2, clamps it to the upper half circle, and normalizes it to a 0–1 position along the arc; apply() then reads that same position into a coordinate for the handle, a hue for its color, and an index into the mood labels. Arrow keys move the position by a fixed 1/(N-1) step in either direction, keeping the keyboard path in sync with dragging.

What the numbers say

Item 08 ties for the most moving frames at 23 of 23 with an 8.37% cumulative area and 88.8 average intensity — the handle's position, color, and label all update together on every frame the pointer or a key moves it.

Arc mood slider — a handle traveling a half-circle to set intensity
Open in a new tab

08 · Arc mood slider — a handle traveling a half-circle to set intensity — Measured area 8.369% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

angleToTMath.atan2hsl(
  // Calculation layer: convert the pointer position to a half-circle angle (180-360deg, left end to right end) with atan2, then normalize to 0-1.
  function angleToT(clientX, clientY) {
    var r = arc.getBoundingClientRect();
    var cx = r.left + r.width / 2, cy = r.bottom;
    var deg = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI; // -180 to 0 (the upper half-circle)
    var clamped = Math.min(0, Math.max(-180, deg));
    return 1 - (clamped + 180) / 180;
  }

  function apply() {
    var deg = -180 + t * 180;
    var x = Math.cos(deg * Math.PI / 180);
    var y = Math.sin(deg * Math.PI / 180);
    handle.style.setProperty('--x', x.toFixed(4));
    handle.style.setProperty('--y', y.toFixed(4));
    handle.style.setProperty('--hue', Math.round(t * 130));
    var idx = Math.min(N - 1, Math.floor(t * N));
    labelEl.textContent = MOODS[idx];
  }

09Keyboard-driven circular control — arrow keys step the dial for accessibility

valueToDeg() maps a 0–100 value onto RANGE_DEG (300) degrees of the ring, and every arrow-key press moves the value by stepPct — the fixed ratio of STEP_DEG (12) to RANGE_DEG — while aria-valuenow and aria-valuetext stay in sync with the exact percentage on every change. The automatic loop steps the value the same way a key press would, just on a timer instead of a keydown event.

What the numbers say

Item 09 has the third-highest average intensity in the set, 150.8, with a small 2.96% cumulative area and 15 of 23 moving frames — each step redraws only the thin handle at its new angle, not the whole ring.

Keyboard-driven circular control — arrow keys step the dial for accessibility
Open in a new tab

09 · Keyboard-driven circular control — arrow keys step the dial for accessibility — Measured area 2.96% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

valueToDegSTEP_DEGaria-valuenow
  // Calculation layer: convert the value (%) to an angle. Within RANGE_DEG, 0% is the start angle and 100% is the end angle.
  function valueToDeg(v) {
    return -RANGE_DEG / 2 + (Math.min(100, Math.max(0, v)) / 100) * RANGE_DEG;
  }

  function apply() {
    handle.style.setProperty('--deg', valueToDeg(value));
    handle.setAttribute('aria-valuenow', String(Math.round(value)));
    handle.setAttribute('aria-valuetext', Math.round(value) + '%');
  }

  function setValue(v) { value = Math.min(100, Math.max(0, v)); apply(); }

  // Calculation layer: one key press = STEP_DEG worth of angle = that same ratio of percent against RANGE_DEG.
  var stepPct = (STEP_DEG / RANGE_DEG) * 100;

Where values change the handle — angle, inertia, snap, and focus basis

The constants that set one step

Each item's response is set by named constants — ARC_SPAN_DEG (200) and RADIUS (96) in 01, STEP_DEG in 02, N segments in 03, DRAG_DECAY (0.92) and MIN_VELOCITY in 04, NOTCH_DEG and SNAP_THRESHOLD_DEG (20) in 05, GAUGE_STEP_DEG (6) and STEP_PCT (10) in 06, SPRING_K (0.18) and SPRING_DAMP (0.72) in 07, N moods in 08, STEP_DEG (12) and RANGE_DEG (300) in 09. A search for css radial menu usually means the fanned-open list in item 01, but the values worth tuning across this set are these per-pattern constants, not the fan angle alone. For reference, item 06's fill-speed factor (0.16) and LOOP_MS are entirely separate from GAUGE_STEP_DEG — lowering the step makes the stair-stepped fill look smoother at the same speed, it does not change how fast the ring fills.

Neighbor shoots — where a radial control sits next to gauges and sliders

A straight percentage without the fan-out layout is the subject of 9 CSS Progress Bar Circle Gauges, Copy-Paste Ready — item 06 here is close to that family, but every other item adds a handle a pointer or a key can move, not just a filling ring. Readers arriving from a css circular menu example that spins on canvas should see Spin Wheel: 9 Canvas Roulettes, Flick to Stop instead — item 02 here turns a DOM disc by angle math on a fixed axis, while 332's wheel is a canvas physics simulation with friction and a launch velocity, a different rendering layer entirely. Linear drag and snap belongs to CSS Range Slider: 9 Physics-Grade Custom Sliders, whose inertia and notch math items 04 and 05 here echo, only bent around a circle instead of a straight track.

Where it breaks — touch and keyboard support, fallback, reduced motion

Pointer Events cover mouse, touch, and pen in one listener set, but only four items — 02, 04, 05, and 08 — call setPointerCapture so a drag keeps tracking past the handle's own edges; the rest either read a single click, like 01 and 06, or only respond to focus and arrow keys, like 09. Keyboard support is not uniform either: 09 is fully operable from the keyboard alone with Home and End jumping to the ends, 01 closes on Escape, 02 and 08 accept arrow keys as an alternative to dragging, 07 triggers its press-and-release with Enter or Space, and 03, 04, and 05 currently have no dedicated key handler beyond receiving focus. Anyone searching for a radial menu css codepen usually wants exactly this: copy-pasteable html, css, and js with no build step and no external library, which is why the code fences above and the zip below both carry the full source rather than a trimmed demo.

Every one of the nine dials, its markup, styling, and script together, sits behind the password ydmj5nha in the zip below, unlocked and ready to tune constant by constant.

What reduced motion leaves behind

Four items keep honoring the reduce flag after a visitor has actually taken over: 04 cuts velocity to zero the instant a drag ends, skipping the coast entirely; 05 sets its settle duration to zero so it snaps straight to the notch with no overshoot; 06 fixes its easing factor at 1 so the displayed fill jumps straight to the target instead of creeping toward it; 07 resolves its scale immediately and zeroes the press-release timeout, so no spring cycle plays. The other five — 01, 02, 03, 08, and 09 — only branch on reduce inside the automatic preview loop: with reduce on and before any interaction, 01 stays open instead of cycling through its 0.12–0.62 open phase, 02 rests at rotation zero, 03 keeps the first segment active, 08 parks its handle at the arc's midpoint, and 09 holds the value at 40% — but once a visitor drags, clicks, or presses a key on one of these five, the JavaScript no longer checks reduce at all, and it is the reduced-motion media query in each item's own SCSS block that strips the transition their real interaction would otherwise animate.

Putting all nine on one page means nine requestAnimationFrame loops running at once, which can drag frames on low-end devices — when you mount these in production, keeping just the one control you need is the safer call.

Three variants and accessibility

Variant What changes What stays
Extend the drag inertia 04 DRAG_DECAY 0.92 → 0.98 MIN_VELOCITY, the coast-to-stop pattern, reduced motion's instant stop
Make the gauge fill smoother 06 GAUGE_STEP_DEG 6 → 3 the 0.16 fill-speed factor, LOOP_MS, STEP_PCT
Tighten the snap threshold 05 SNAP_THRESHOLD_DEG 20 → 10 NOTCH_DEG, the overshoot curve, SETTLE_MS

Raising 04's DRAG_DECAY from 0.92 closer to 1 increases the per-frame decay multiplier applied to velocity inside decayStep(), so speed falls off more slowly after release and the coast lasts longer — the direction and the MIN_VELOCITY cutoff stay exactly the same. Lowering 06's GAUGE_STEP_DEG from 6 to 3 only changes the rounding unit quantizedDeg() snaps the displayed angle to; it has nothing to do with how fast the ring fills, since that speed comes from a separate 0.16 lerp factor and LOOP_MS untouched by this constant — a smaller step just makes the same stair-stepped fill look smoother. Tightening 05's SNAP_THRESHOLD_DEG from 20 to 10 shrinks the distance nearestNotch() will accept before triggering a snap (n.dist <= SNAP_THRESHOLD_DEG), so the release point has to land closer to a notch before the dial pulls it in, making the snap less forgiving.

Full keyboard control belongs to items 02, 06, 07, 08, and 09: 02's arrow keys rotate the wheel by one STEP_DEG at a time, 06's two buttons are native and need no extra script, 07's Enter and Space trigger the same press-and-release the pointer does, 08's arrow keys move the arc position by a fixed step, and 09 is a full role="slider" with arrow keys, Home, and End all wired to setValue(). Item 01 closes on Escape but has no way to move between items by keyboard alone, and 03, 04, and 05 currently only receive focus without a dedicated key handler, so dragging is the only way to change their value. aria-live is not used anywhere in the set; state reaches assistive technology only through the aria-valuenow/aria-valuetext pair on 09 and the aria-expanded/aria-hidden pair on 01, so a screen reader user gets the clearest feedback from those two.

FAQ

Is a css pie menu the same as this radial menu?

Not quite — a pie menu usually gives every slice an equal hit area shaped like the slice itself, while item 01 here fans out individual round buttons along an arc and leaves the space between them inert. If you want slice-shaped hit targets instead of discrete buttons, you would replace 01's button list with conic-gradient-cut wedge elements, keeping the same angle math that places them.

Why do some of the nine snap or spring back instead of just stopping where the pointer let go?

Three items — 04, 05, and 07 — keep changing after the pointer lifts because their state lives in a physics loop (decay, snap-and-overshoot, or spring), not just in the last pointer position; 04 is a pure momentum sim while 05 and 07 aim for a specific target value and use different curves to get there. Items without this behavior, like 02, 06, and 09, hold their value exactly where the interaction leaves it, since there is nothing left for the loop to resolve.

Which pattern here handles the most calculation per frame?

By moving-frame count, items 02, 06, and 08 lead at 23 of 23 sampled frames, since a wheel that is always mid-spin, a gauge that recalculates its fill every tick, and a slider whose color and position are coupled all have something new to compute almost constantly. By average intensity, item 06 leads at 181.2, because its hard-edged GAUGE_STEP_DEG steps produce a bigger per-pixel jump than a smooth transition would; the actual math behind any of the nine is light enough to run on the browser's main thread without a worker.

Sources: MDN — conic-gradient() · MDN — Pointer events · MDN — menu role · MDN — Web Animations API

Enter the archive password

The password is inside this article. You will find it as you read.