A ring of items laid out from `wrappedOffset(i, position, count)` — the shortest signed distance around the circle — with the naive `i - position` printed beside each one so the two can be compared while the ring turns. The dots move continuously through the seam; the raw numbers leap by a whole count at it. This is the single function that makes a finite array behave like an infinite one, and the only honest way to teach it is to show the version it replaces failing.
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Shortest Signed Ring OffsetA ring of items laid out from `wrappedOffset(i, position, count)` — the shortest signed distance around the circle — with the naive `i - position` printed beside each one so the two can be compared while the ring turns. The dots move continuously through the seam; the raw numbers leap by a whole count at it. This is the single function that makes a finite array behave like an infinite one, and the only honest way to teach it is to show the version it replaces failing.Build this as a React component in my project. Match my existing conventions —file layout, styling system, and naming — rather than the ones implied below.Everything after this line is specification, not code to copy verbatim.## What it doesTen dots on a ring, each showing its wrapped offset and the raw index difference it replaces.- Category: primitive- Runtime cost: dom- Dependencies: none beyond React## How it works1. `wrappedOffset(i, position, count)` computes `mod(i - position, count)` and then subtracts `count` when the result exceeds `count / 2`, producing a signed distance in `(-count/2, count/2]`. Negative means the item is behind the read head.2. Each item is placed at `angle = (offset / count) * 2PI - PI/2`, so offset 0 sits under the notch at twelve o'clock and the ring is symmetric about it. Because a ±count jump in the offset is exactly one full turn, the dot lands on the same pixel and the seam is invisible.3. Focus is `gaussian(|offset|, 0.45)` — `exp(-(0.45·d)²)` — driving scale from 0.74 to 1.20 and opacity from 0.30 to 1.00. A continuous falloff with no thresholds is what makes the ring read as analogue rather than stepped.4. Dragging converts the pointer to an angle about the ring centre with `atan2`, then advances the target position by `shortestAngle(previous, current) / 2PI * count`. `shortestAngle` is required here for the same reason `wrappedOffset` is required for the layout: `atan2` flips from +PI to -PI at nine o'clock.5. The ring position is eased with `damp(position, target, 0.0015, dt)` on the shared rAF loop, and every frame writes only `transform`, `opacity` and `z-index` — no layout reads, because width and height come from a ResizeObserver rather than `clientWidth`.6. React re-renders only when `wrapIndex(Math.round(position), count)` changes, which is a couple of times a second; the sixty-times-a-second work is plain style writes on refs.## Constants — use these valuesThese are tuned. Each one has a reason; do not round them off or replace themwith values that "look cleaner".- `count = 10` Items on the ring, and therefore the wrap threshold at count / 2 = 5. Ten keeps every raw label at most two characters wide, so the pills fit on a 320 px preview without colliding; above ~16 the labels overlap tangentially and the comparison becomes unreadable.- `FOCUS_FALLOFF = 0.45` Gaussian falloff on |offset|. Yields 1.00 / 0.82 / 0.44 / 0.16 / 0.04 at distances 0-4, so roughly four items read as "near" on a ten-item ring. At 1.0 only the centre item is lit and the ring looks broken; at 0.2 nothing falls off and there is no focus at all.- `SMOOTHING = 0.0015` Fraction of the remaining gap left after one second. Fast enough that the ring feels attached to the pointer, slow enough that releasing mid-drag reads as momentum rather than a cut. This is `damp`'s smoothing argument, not a per-frame lerp constant — a per-frame constant converges twice as fast at 120 Hz.- `RADIUS_RATIO = 0.36` Ring radius as a fraction of the shorter container side. Above ~0.42 the 36 px pills clip the edges of a 16:10 frame; below ~0.30 adjacent pills touch.- `IDLE_DRIFT = 0.22` Ring positions per second before the first interaction, so the seam sweeps past roughly every 45 s and the component demonstrates itself without being touched. Faster than ~0.6 and the raw labels change too quickly to read.- `threshold (useDrag) = 3` Pixels of travel before a press becomes a rotation. Lower than the 8 px a carousel wants, because this ring has no click target to protect — it only has to reject hand tremor.## API```tsfunction WrappedRingOffset(props: { count?: number; drift?: number; className?: string }): JSX.Element```- `count: number` — default `10` Items on the ring. The wrap threshold is count / 2.- `drift: number` — default `0.22` Ring positions per second before the first interaction. 0 to sit still.- `className: string` Merged onto the outer element via `cn`.## Reference implementation of the coreThis is the part that is hard to get right. Adapt it; do not treat the rest ofthe component as fixed.```tsx/** Fraction of the gap to the target that survives one second. */const SMOOTHING = 0.0015;/** Gaussian falloff on |offset|. At 0.45: 1.00 / 0.82 / 0.44 / 0.16 / 0.04. */const FOCUS_FALLOFF = 0.45;const RADIUS_RATIO = 0.36;const IDLE_DRIFT = 0.22;export function WrappedRingOffset({ count = 10, drift = IDLE_DRIFT, className,}: WrappedRingOffsetProps) { const root = useRef<HTMLDivElement>(null); const nodes = useRef<(HTMLLIElement | null)[]>([]); /** Where the ring is being pulled to. Fractional and unbounded. */ const target = useRef(0); /** Where the ring actually is. Eased toward `target`. */ const position = useRef(0); /** Suppresses the idle drift once a human has touched it. */ const engaged = useRef(false); const grabAngle = useRef(0); const shown = useRef(0); const [index, setIndex] = useState(0); const reduced = useReducedMotion(); const { width, height } = useResizeObserver(root); /** Pointer angle around the ring centre, measured fresh: the page scrolls. */ const angleAt = useCallback((clientX: number, clientY: number) => { const box = root.current?.getBoundingClientRect(); if (!box) return 0; return Math.atan2(clientY - (box.top + box.height / 2), clientX - (box.left + box.width / 2)); }, []); useDrag(root, { threshold: 3, onDragStart: (s) => { engaged.current = true; grabAngle.current = angleAt(s.clientX, s.clientY); }, onDrag: (s) => { const angle = angleAt(s.clientX, s.clientY); // shortestAngle, not `angle - grabAngle`: atan2 flips from +PI to -PI at // nine o'clock, and the raw difference there is a full turn of garbage // that spins the ring backwards every time the pointer crosses it. const step = (shortestAngle(grabAngle.current, angle) / TAU) * count; target.current -= step; grabAngle.current = angle; }, }); useRaf((dt) => { if (!engaged.current && !reduced) target.current += drift * dt; // Reduced motion still moves the ring — it just does not ease into place. position.current = reduced ? target.current : damp(position.current, target.current, SMOOTHING, dt); const p = position.current; const radius = Math.min(width, height) * RADIUS_RATIO; for (let i = 0; i < count; i++) { const el = nodes.current[i]; if (!el) continue; // The one line the whole component exists to demonstrate. `offset` is // continuous in `p` even across the seam, because the ±count jump maps // to exactly one turn of the circle and lands on the same place. const offset = wrappedOffset(i, p, count); const angle = (offset / count) * TAU - Math.PI / 2; const focus = gaussian(Math.abs(offset), FOCUS_FALLOFF); const x = Math.cos(angle) * radius; const y = Math.sin(angle) * radius; // translate3d places the box origin, translate(-50%,-50%) re-centres it, // scale runs about that centre. One string, one composited property. el.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) translate(-50%, -50%) scale(${(0.74 + focus * 0.46).toFixed(3)})`; el.style.opacity = (0.3 + focus * 0.7).toFixed(3); el.style.zIndex = String(10 + Math.round(focus * 40)); } // Labels are integers, so they only need a render when the rounded ring // position changes — a couple of times a second, not sixty. const next = wrapIndex(Math.round(p), count); if (next !== shown.current) { shown.current = next; setIndex(next); } });```## RequirementsThese are not optional. A version of this component that skips any of them isnot finished.**Cleanup.** `useDrag` removes its five pointer listeners and releases any outstanding pointer capture; `useRaf` unsubscribes and cancels the shared frame when it was the last subscriber; `useResizeObserver` unobserves the root. No transforms are left behind because the nodes unmount with the component.**Reduced motion.** The idle drift never starts and the `damp` easing is bypassed, so the ring tracks the drag one-to-one and stops dead on release. Dragging and the arrow keys still work, and the labels still update — the maths is the content here, so removing it would remove the component.**Frame-rate independence.** Any smoothing must be a function of delta time.`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where`smoothing` is the fraction of the gap remaining after one second.**Strict mode.** Assume effects run twice in development. Setup must beidempotent and teardown must fully undo it.## Known pitfalls — avoid these specifically- Using `i - position` for layout. The ring still turns, but item 0 renders nine slots away from item 9 instead of one slot back, so the last item snaps across the entire ring every time the position passes zero. That snap is the seam, and it is the whole reason this function exists.- Using `%` instead of a true modulo. JavaScript's `%` returns a negative result for negative operands, so scrolling backwards past zero produces offsets outside `[0, count)` and half the ring inverts. `mod(n, m) = ((n % m) + m) % m` is the fix.- Comparing `offset > count / 2` with `>=`. On an even count that puts the antipodal item at `-count/2` instead of `+count/2`, and the item directly opposite the read head flickers between the two on alternate frames.- Taking `angle - previousAngle` when converting pointer motion to rotation. `atan2` wraps from +PI to -PI at nine o'clock, so the raw difference there is a full turn and the ring spins backwards once per lap. `shortestAngle` wraps the difference into `(-PI, PI]`.- Reading `element.clientWidth` inside the rAF callback to compute the radius. The same loop has already written transforms, so every read forces a synchronous reflow — sixty per second, and it only shows up as jank once there are a few of these on a page. The size comes from a ResizeObserver instead.- Driving the per-item transforms through React state. Sixty commits a second for ten nodes is an order of magnitude more work than writing `style.transform` on refs, and it makes the labels — which change twice a second — re-render at frame rate for nothing.## Notes- `wrappedOffset` is the same three lines behind an infinite marquee, a drum carousel, a cylinder of image planes, and any "closest N to the playhead" query. Pair it with a scroll library set to `infinite: true` and the sequence has no beginning and no end.- The idle drift stops permanently at the first drag or arrow key. An ambient motion that resumes on top of a user's input reads as the component fighting them.## Definition of doneLeave it alone: the ring drifts and, as each item passes the six o'clock antipode, its struck-through raw label flips sign by the full count while the dot itself does not move. Drag the ring in a full circle past nine o'clock and it keeps turning in the same direction. Press ArrowRight and the position readout increments by one. Resize the frame and the ring re-radiuses without the dots drifting off-centre.When you are finished, tell me which of the requirements above you implementedand where, so I can check them.
'use client';import { useCallback, useRef, useState, type KeyboardEvent } from 'react';import { cn } from '@/lib/utils';import { useDrag } from '@/hooks/use-drag';import { useRaf } from '@/hooks/use-raf';import { useReducedMotion } from '@/hooks/use-reduced-motion';import { useResizeObserver } from '@/hooks/use-resize-observer';import { damp, gaussian } from '@/lib/damp';import { shortestAngle, wrapIndex, wrappedOffset } from '@/lib/wrap';/* ========================================================================== Shortest Signed Ring Offset -------------------------------------------------------------------------- A visualiser for one function. Every looping layout — drum carousel, infinite marquee, cylinder of planes — needs to answer "how far is item i from position p" as if the list were a circle. `i - p` answers it as if the list were a line, and the difference is a hard seam you can see. This renders both answers at once: the dots are laid out from the WRAPPED offset, and each dot carries the RAW difference beside it. Rotate the ring and the raw numbers jump by ±count while the dots do not move at all. ========================================================================== */export interface WrappedRingOffsetProps { /** Items on the ring. The wrap threshold is count / 2. */ count?: number; /** Ring positions per second before the first interaction. 0 to sit still. */ drift?: number; className?: string;}const TAU = Math.PI * 2;// #region critical/** Fraction of the gap to the target that survives one second. */const SMOOTHING = 0.0015;/** Gaussian falloff on |offset|. At 0.45: 1.00 / 0.82 / 0.44 / 0.16 / 0.04. */const FOCUS_FALLOFF = 0.45;const RADIUS_RATIO = 0.36;const IDLE_DRIFT = 0.22;export function WrappedRingOffset({ count = 10, drift = IDLE_DRIFT, className,}: WrappedRingOffsetProps) { const root = useRef<HTMLDivElement>(null); const nodes = useRef<(HTMLLIElement | null)[]>([]); /** Where the ring is being pulled to. Fractional and unbounded. */ const target = useRef(0); /** Where the ring actually is. Eased toward `target`. */ const position = useRef(0); /** Suppresses the idle drift once a human has touched it. */ const engaged = useRef(false); const grabAngle = useRef(0); const shown = useRef(0); const [index, setIndex] = useState(0); const reduced = useReducedMotion(); const { width, height } = useResizeObserver(root); /** Pointer angle around the ring centre, measured fresh: the page scrolls. */ const angleAt = useCallback((clientX: number, clientY: number) => { const box = root.current?.getBoundingClientRect(); if (!box) return 0; return Math.atan2(clientY - (box.top + box.height / 2), clientX - (box.left + box.width / 2)); }, []); useDrag(root, { threshold: 3, onDragStart: (s) => { engaged.current = true; grabAngle.current = angleAt(s.clientX, s.clientY); }, onDrag: (s) => { const angle = angleAt(s.clientX, s.clientY); // shortestAngle, not `angle - grabAngle`: atan2 flips from +PI to -PI at // nine o'clock, and the raw difference there is a full turn of garbage // that spins the ring backwards every time the pointer crosses it. const step = (shortestAngle(grabAngle.current, angle) / TAU) * count; target.current -= step; grabAngle.current = angle; }, }); useRaf((dt) => { if (!engaged.current && !reduced) target.current += drift * dt; // Reduced motion still moves the ring — it just does not ease into place. position.current = reduced ? target.current : damp(position.current, target.current, SMOOTHING, dt); const p = position.current; const radius = Math.min(width, height) * RADIUS_RATIO; for (let i = 0; i < count; i++) { const el = nodes.current[i]; if (!el) continue; // The one line the whole component exists to demonstrate. `offset` is // continuous in `p` even across the seam, because the ±count jump maps // to exactly one turn of the circle and lands on the same place. const offset = wrappedOffset(i, p, count); const angle = (offset / count) * TAU - Math.PI / 2; const focus = gaussian(Math.abs(offset), FOCUS_FALLOFF); const x = Math.cos(angle) * radius; const y = Math.sin(angle) * radius; // translate3d places the box origin, translate(-50%,-50%) re-centres it, // scale runs about that centre. One string, one composited property. el.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) translate(-50%, -50%) scale(${(0.74 + focus * 0.46).toFixed(3)})`; el.style.opacity = (0.3 + focus * 0.7).toFixed(3); el.style.zIndex = String(10 + Math.round(focus * 40)); } // Labels are integers, so they only need a render when the rounded ring // position changes — a couple of times a second, not sixty. const next = wrapIndex(Math.round(p), count); if (next !== shown.current) { shown.current = next; setIndex(next); } }); // #endregion critical const onKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => { const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; if (step === 0) return; e.preventDefault(); engaged.current = true; target.current += step; }, []); const signed = (n: number) => (n > 0 ? `+${n}` : `${n}`); return ( <div className={cn('relative flex h-full w-full flex-col', className)}> <div ref={root} role="slider" tabIndex={0} aria-label="Ring position" aria-valuemin={0} aria-valuemax={count - 1} aria-valuenow={index} onKeyDown={onKeyDown} className="relative min-h-0 flex-1 cursor-grab touch-none outline-none active:cursor-grabbing" > {/* The read head. Offset 0 is whatever sits under this notch. */} <div className="pointer-events-none absolute left-1/2 top-1/2 h-full w-px -translate-x-1/2 -translate-y-1/2"> <div className="absolute left-1/2 top-[7%] h-[36%] w-px -translate-x-1/2 bg-gradient-to-b from-accent/60 to-transparent" /> <div className="absolute left-1/2 top-[5%] size-1.5 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-accent" /> </div> <ul className="absolute left-1/2 top-1/2 size-0 list-none"> {Array.from({ length: count }, (unused, i) => { const raw = i - index; const wrapped = wrappedOffset(i, index, count); const disagrees = raw !== wrapped; return ( <li key={i} ref={(el) => { nodes.current[i] = el; }} className="absolute left-0 top-0 will-change-transform" > <div className={cn( 'relative grid size-9 place-items-center rounded-full border font-mono text-[11px] tabular-nums', wrapped === 0 ? 'border-accent bg-accent text-bg' : disagrees ? 'border-line-strong bg-surface text-fg' : 'border-line bg-bg-subtle text-muted', )} > {signed(wrapped)} {disagrees && ( <span className="absolute -right-1 -top-2 rounded-sm bg-bg px-1 font-mono text-[9px] leading-tight text-faint line-through"> {signed(raw)} </span> )} </div> </li> ); })} </ul> </div> <div className="flex shrink-0 items-center justify-between gap-4 border-t border-line px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.08em]"> <span className="text-muted"> p <span className="text-fg tabular-nums">{index}</span> <span className="px-1.5 text-faint">/</span>n{' '} <span className="text-fg tabular-nums">{count}</span> </span> <span className="flex items-center gap-3"> <span className="text-fg">wrapped</span> <span className="text-faint line-through">i − p</span> </span> </div> </div> );}
/* ========================================================================== Frame-rate-independent interpolation. -------------------------------------------------------------------------- The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice as fast on a 120 Hz display as on a 60 Hz one, so the same code feels different on different hardware and visibly speeds up when the browser drops frames. `damp` fixes it by raising the retention factor to the power of elapsed time, which is the exact continuous form of the same curve. ========================================================================== *//** * Fraction of the remaining distance to close this frame. * * @param smoothing Fraction of the gap that REMAINS after one second. * Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft, * 0.5 ≈ slow drift. * @param dt Seconds elapsed since the previous frame. */export function dampFactor(smoothing: number, dt: number): number { return 1 - Math.pow(smoothing, dt);}/** Move `current` toward `target` at a rate independent of frame rate. */export function damp( current: number, target: number, smoothing: number, dt: number,): number { return current + (target - current) * dampFactor(smoothing, dt);}/** * The per-frame lerp constant an author measured at 60fps, converted into the * `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame" * be ported without re-tuning by eye: * * damp(x, t, perFrameToSmoothing(0.1), dt) */export function perFrameToSmoothing(alpha: number, baseFps = 60): number { return Math.pow(1 - alpha, baseFps);}export function lerp(a: number, b: number, t: number): number { return a + (b - a) * t;}export function clamp(v: number, min: number, max: number): number { return v < min ? min : v > max ? max : v;}export function inverseLerp(a: number, b: number, v: number): number { return a === b ? 0 : (v - a) / (b - a);}export function mapRange( v: number, inMin: number, inMax: number, outMin: number, outMax: number, doClamp = true,): number { const t = inverseLerp(inMin, inMax, v); const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t); return r;}/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */export function smoothstep(edge0: number, edge1: number, x: number): number { const t = clamp(inverseLerp(edge0, edge1, x), 0, 1); return t * t * (3 - 2 * t);}/** * Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at * distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and * no branches, which is why carousels that use it feel analogue rather than * stepped. */export function gaussian(distance: number, falloff = 1): number { const d = distance * falloff; return Math.exp(-(d * d));}
'use client';import { useEffect, useRef, type RefObject } from 'react';/* ========================================================================== Pointer drag with capture, a dead zone, and a click gate. -------------------------------------------------------------------------- Three things every hand-rolled drag gets wrong: 1. No pointer capture. Drag fast, leave the element, and `pointermove` stops firing — the track sticks mid-throw. `setPointerCapture` keeps events coming to the origin element until release. 2. No dead zone. Without one, a 1 px wobble during a click is a drag, and the drag handler fights the click handler on every tap. 3. The click still fires. After a real drag, the browser dispatches `click` on whatever is under the pointer, so throwing a carousel opens whichever slide you happen to release over. Swallowing that one click in the capture phase is the fix; `onDragEnd`'s `moved` flag tells the caller whether it happened. ========================================================================== */export interface DragState { /** Pixels from the press point. */ dx: number; dy: number; /** Pixels since the previous move event. */ deltaX: number; deltaY: number; /** Viewport pixels. */ clientX: number; clientY: number;}export interface UseDragOptions { /** Pixels of travel before it counts as a drag. Default 4. */ threshold?: number; /** Ignore the wrong mouse buttons / a secondary touch. Default true. */ primaryOnly?: boolean; onDragStart?: (state: DragState) => void; onDrag?: (state: DragState) => void; /** `moved` is false for a tap: the caller should treat it as a click. */ onDragEnd?: (state: DragState, moved: boolean) => void; enabled?: boolean;}export function useDrag( ref: RefObject<HTMLElement | null>, options: UseDragOptions = {},): { current: boolean } { const { threshold = 4, primaryOnly = true, enabled = true } = options; const dragging = useRef(false); const handlers = useRef(options); handlers.current = options; useEffect(() => { const el = ref.current; if (!el || !enabled) return; let pointerId: number | null = null; let startX = 0; let startY = 0; let lastX = 0; let lastY = 0; let passed = false; let swallowClick = false; const stateAt = (clientX: number, clientY: number): DragState => ({ dx: clientX - startX, dy: clientY - startY, deltaX: clientX - lastX, deltaY: clientY - lastY, clientX, clientY, }); const onPointerDown = (e: PointerEvent) => { if (primaryOnly && !e.isPrimary) return; if (e.pointerType === 'mouse' && e.button !== 0) return; pointerId = e.pointerId; startX = lastX = e.clientX; startY = lastY = e.clientY; passed = false; // Capture on press, not on threshold: by the time the threshold is // crossed the pointer may already be outside the element. el.setPointerCapture(e.pointerId); }; const onPointerMove = (e: PointerEvent) => { if (pointerId !== e.pointerId) return; const state = stateAt(e.clientX, e.clientY); if (!passed) { if (Math.hypot(state.dx, state.dy) < threshold) return; passed = true; dragging.current = true; handlers.current.onDragStart?.(state); } handlers.current.onDrag?.(state); lastX = e.clientX; lastY = e.clientY; }; const finish = (e: PointerEvent) => { if (pointerId !== e.pointerId) return; const state = stateAt(e.clientX, e.clientY); const moved = passed; if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId); pointerId = null; passed = false; dragging.current = false; swallowClick = moved; handlers.current.onDragEnd?.(state, moved); }; // Capture phase, so it runs before any child's own click handler. const onClick = (e: MouseEvent) => { if (!swallowClick) return; swallowClick = false; e.preventDefault(); e.stopPropagation(); }; el.addEventListener('pointerdown', onPointerDown); el.addEventListener('pointermove', onPointerMove); el.addEventListener('pointerup', finish); el.addEventListener('pointercancel', finish); el.addEventListener('click', onClick, true); return () => { el.removeEventListener('pointerdown', onPointerDown); el.removeEventListener('pointermove', onPointerMove); el.removeEventListener('pointerup', finish); el.removeEventListener('pointercancel', finish); el.removeEventListener('click', onClick, true); if (pointerId !== null && el.hasPointerCapture(pointerId)) { el.releasePointerCapture(pointerId); } dragging.current = false; }; }, [ref, threshold, primaryOnly, enabled]); return dragging;}
'use client';import { useEffect, useRef } from 'react';/* ========================================================================== One shared requestAnimationFrame loop. -------------------------------------------------------------------------- Forty components each calling requestAnimationFrame means forty callbacks the browser schedules separately and forty chances to read layout after a write. One loop, one ordered pass, one place to clamp delta time. Delta is clamped to 100 ms. Without that, returning to a backgrounded tab delivers a multi-second dt and every physics integration in the page explodes on the first frame back. ========================================================================== */export type RafCallback = (dt: number, elapsed: number) => void;const callbacks = new Set<RafCallback>();let frame = 0;let last = 0;let start = 0;const MAX_DT = 0.1;const DEFAULT_DT = 1 / 60;function tick(now: number) { frame = requestAnimationFrame(tick); const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT); last = now; const elapsed = (now - start) / 1000; // Copy: a callback may unsubscribe itself (or another) mid-iteration. for (const cb of Array.from(callbacks)) { if (callbacks.has(cb)) cb(dt, elapsed); }}function subscribe(cb: RafCallback) { callbacks.add(cb); if (frame === 0) { start = performance.now(); last = 0; frame = requestAnimationFrame(tick); } return () => { callbacks.delete(cb); if (callbacks.size === 0 && frame !== 0) { cancelAnimationFrame(frame); frame = 0; last = 0; } };}/** * Run `cb` every frame while `enabled`. * * The callback is held in a ref, so it does not need to be memoised and the * subscription is never torn down just because the closure changed identity. */export function useRaf(cb: RafCallback, enabled = true): void { const ref = useRef(cb); ref.current = cb; useEffect(() => { if (!enabled) return; return subscribe((dt, elapsed) => ref.current(dt, elapsed)); }, [enabled]);}
'use client';import { useSyncExternalStore } from 'react';/* ========================================================================== prefers-reduced-motion, SSR-safe. -------------------------------------------------------------------------- useSyncExternalStore rather than useState + useEffect: the server snapshot is an explicit `false`, so the markup React renders on the server and the markup it hydrates with agree, and the real value lands in the same commit as hydration instead of one frame later. A useEffect version flashes the animated variant before removing it — the exact thing the user asked not to see. Reduced motion is not "duration: 0". For most components here it means a different render: the final state, a static poster, or a paused frame. ========================================================================== */const QUERY = '(prefers-reduced-motion: reduce)';function subscribe(onChange: () => void) { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(QUERY); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange);}function getSnapshot() { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(QUERY).matches;}function getServerSnapshot() { return false;}export function useReducedMotion(): boolean { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);}/** Generic media-query hook, same SSR guarantees. */export function useMediaQuery(query: string): boolean { return useSyncExternalStore( (onChange) => { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(query); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, () => { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(query).matches; }, () => false, );}
'use client';import { useEffect, useState, type RefObject } from 'react';/* ========================================================================== Element size, from one shared ResizeObserver. -------------------------------------------------------------------------- Sized from the element rather than the window because a preview in a grid cell resizes when the grid reflows, not when the viewport does. Reading `window.innerWidth` is the reason so many canvas components are correct on load and wrong after a sidebar opens. ========================================================================== */export interface Size { width: number; height: number;}type Handler = (size: Size) => void;let observer: ResizeObserver | null = null;const handlers = new Map<Element, Handler>();function ensureObserver(): ResizeObserver | null { if (typeof ResizeObserver === 'undefined') return null; observer ??= new ResizeObserver((entries) => { for (const entry of entries) { const box = entry.contentBoxSize?.[0]; const size: Size = box ? { width: box.inlineSize, height: box.blockSize } : { width: entry.contentRect.width, height: entry.contentRect.height }; handlers.get(entry.target)?.(size); } }); return observer;}export function useResizeObserver(ref: RefObject<Element | null>): Size { const [size, setSize] = useState<Size>({ width: 0, height: 0 }); useEffect(() => { const el = ref.current; const ro = ensureObserver(); if (!el || !ro) return; // Seed synchronously: waiting for the first observer callback leaves one // frame where a canvas is sized 0x0, which some drivers log as an error. const rect = el.getBoundingClientRect(); setSize({ width: rect.width, height: rect.height }); handlers.set(el, (next) => setSize((prev) => prev.width === next.width && prev.height === next.height ? prev : next, ), ); ro.observe(el); return () => { handlers.delete(el); ro.unobserve(el); }; }, [ref]); return size;}
/* ========================================================================== Ring index arithmetic. -------------------------------------------------------------------------- Anything that loops — a drum carousel, an infinite marquee, a cylinder of image planes — needs to answer "how far is item i from position p" in a way that treats the sequence as a circle. A plain `i - p` says item 0 is 18 away from item 18 in a list of 19, when really it is 1 step back. ========================================================================== *//** True modulo. Unlike `%`, the result is never negative. */export function mod(n: number, m: number): number { return ((n % m) + m) % m;}/** * Shortest signed distance from `position` to index `i` around a ring of * `count` items. Result is in `(-count/2, count/2]`; negative means "behind". * * This one function is what makes a finite array behave like an infinite one. */export function wrappedOffset(i: number, position: number, count: number): number { if (count <= 0) return 0; let offset = mod(i - position, count); if (offset > count / 2) offset -= count; return offset;}/** Wrap `value` into `[min, max)`, e.g. an angle into `[-PI, PI)`. */export function wrapRange(value: number, min: number, max: number): number { const span = max - min; if (span <= 0) return min; return mod(value - min, span) + min;}/** Wrap an index into `[0, count)`. Accepts fractional and negative input. */export function wrapIndex(i: number, count: number): number { return count <= 0 ? 0 : mod(i, count);}/** * Shortest signed angular distance from `a` to `b` in radians, in `(-PI, PI]`. * Prevents a rotation tween from taking the long way round. */export function shortestAngle(a: number, b: number): number { return wrapRange(b - a, -Math.PI, Math.PI);}
05
API
Every parameter, its type and what happens when you change it.
Items on the ring. The wrap threshold is count / 2.
drift
number
= 0.22
Ring positions per second before the first interaction. 0 to sit still.
className
string
—
Merged onto the outer element via cn.
06
How it works
The technique in order, not the code in order.
01
wrappedOffset(i, position, count) computes mod(i - position, count) and then subtracts count when the result exceeds count / 2, producing a signed distance in (-count/2, count/2]. Negative means the item is behind the read head.
02
Each item is placed at angle = (offset / count) * 2PI - PI/2, so offset 0 sits under the notch at twelve o'clock and the ring is symmetric about it. Because a ±count jump in the offset is exactly one full turn, the dot lands on the same pixel and the seam is invisible.
03
Focus is gaussian(|offset|, 0.45) — exp(-(0.45·d)²) — driving scale from 0.74 to 1.20 and opacity from 0.30 to 1.00. A continuous falloff with no thresholds is what makes the ring read as analogue rather than stepped.
04
Dragging converts the pointer to an angle about the ring centre with atan2, then advances the target position by shortestAngle(previous, current) / 2PI * count. shortestAngle is required here for the same reason wrappedOffset is required for the layout: atan2 flips from +PI to -PI at nine o'clock.
05
The ring position is eased with damp(position, target, 0.0015, dt) on the shared rAF loop, and every frame writes only transform, opacity and z-index — no layout reads, because width and height come from a ResizeObserver rather than clientWidth.
06
React re-renders only when wrapIndex(Math.round(position), count) changes, which is a couple of times a second; the sixty-times-a-second work is plain style writes on refs.
Load-bearing constants
Load-bearing constants
Constant
Value
Why this number
count
= 10
Items on the ring, and therefore the wrap threshold at count / 2 = 5. Ten keeps every raw label at most two characters wide, so the pills fit on a 320 px preview without colliding; above ~16 the labels overlap tangentially and the comparison becomes unreadable.
FOCUS_FALLOFF
= 0.45
Gaussian falloff on |offset|. Yields 1.00 / 0.82 / 0.44 / 0.16 / 0.04 at distances 0-4, so roughly four items read as "near" on a ten-item ring. At 1.0 only the centre item is lit and the ring looks broken; at 0.2 nothing falls off and there is no focus at all.
SMOOTHING
= 0.0015
Fraction of the remaining gap left after one second. Fast enough that the ring feels attached to the pointer, slow enough that releasing mid-drag reads as momentum rather than a cut. This is damp's smoothing argument, not a per-frame lerp constant — a per-frame constant converges twice as fast at 120 Hz.
RADIUS_RATIO
= 0.36
Ring radius as a fraction of the shorter container side. Above ~0.42 the 36 px pills clip the edges of a 16:10 frame; below ~0.30 adjacent pills touch.
IDLE_DRIFT
= 0.22
Ring positions per second before the first interaction, so the seam sweeps past roughly every 45 s and the component demonstrates itself without being touched. Faster than ~0.6 and the raw labels change too quickly to read.
threshold (useDrag)
= 3
Pixels of travel before a press becomes a rotation. Lower than the 8 px a carousel wants, because this ring has no click target to protect — it only has to reject hand tremor.
07
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The idle drift never starts and the damp easing is bypassed, so the ring tracks the drag one-to-one and stops dead on release. Dragging and the arrow keys still work, and the labels still update — the maths is the content here, so removing it would remove the component.
Cleanup
useDrag removes its five pointer listeners and releases any outstanding pointer capture; useRaf unsubscribes and cancels the shared frame when it was the last subscriber; useResizeObserver unobserves the root. No transforms are left behind because the nodes unmount with the component.
Definition of done
Leave it alone: the ring drifts and, as each item passes the six o'clock antipode, its struck-through raw label flips sign by the full count while the dot itself does not move. Drag the ring in a full circle past nine o'clock and it keeps turning in the same direction. Press ArrowRight and the position readout increments by one. Resize the frame and the ring re-radiuses without the dots drifting off-centre.
Pitfalls
Using i - position for layout. The ring still turns, but item 0 renders nine slots away from item 9 instead of one slot back, so the last item snaps across the entire ring every time the position passes zero. That snap is the seam, and it is the whole reason this function exists.
Using % instead of a true modulo. JavaScript's % returns a negative result for negative operands, so scrolling backwards past zero produces offsets outside [0, count) and half the ring inverts. mod(n, m) = ((n % m) + m) % m is the fix.
Comparing offset > count / 2 with >=. On an even count that puts the antipodal item at -count/2 instead of +count/2, and the item directly opposite the read head flickers between the two on alternate frames.
Taking angle - previousAngle when converting pointer motion to rotation. atan2 wraps from +PI to -PI at nine o'clock, so the raw difference there is a full turn and the ring spins backwards once per lap. shortestAngle wraps the difference into (-PI, PI].
Reading element.clientWidth inside the rAF callback to compute the radius. The same loop has already written transforms, so every read forces a synchronous reflow — sixty per second, and it only shows up as jank once there are a few of these on a page. The size comes from a ResizeObserver instead.
Driving the per-item transforms through React state. Sixty commits a second for ten nodes is an order of magnitude more work than writing style.transform on refs, and it makes the labels — which change twice a second — re-render at frame rate for nothing.
Notes
wrappedOffset is the same three lines behind an infinite marquee, a drum carousel, a cylinder of image planes, and any "closest N to the playhead" query. Pair it with a scroll library set to infinite: true and the sequence has no beginning and no end.
The idle drift stops permanently at the first drag or arrow key. An ambient motion that resumes on top of a user's input reads as the component fighting them.