ns-ui
Idler Drop
Delegated admin drawn as a gear train — your authority is the drive gear, each delegate is a driven gear whose tooth count sets a discrete role ratio, and revoking swings the idler out so their gear freewheels to a stop while yours keeps turning.
Use when a delegated-admin or role-assignment list where the point is that granted authority is a live, derived coupling to the owner's own account — ratio-limited to legal roles, severable at will — rather than a possession the delegate now holds outright; revoking is modeled as breaking a mechanical mesh, and the delegate's gear visibly freewheeling afterward is what discloses that their existing session outlives the grant until it expires. Distinct from cog-rail and the pawl family, which use teeth purely as motion texture with no meaning in the tooth count itself: here the tooth RATIO (12 admin / 8 editor / 3 viewer against a fixed 24-tooth drive) is the actual payload, and disengagement-plus-freewheel is a security fact being disclosed, not decoration. Reach for a plain role-badge table instead when the audience just needs to scan current roles with no interest in how the coupling behaves under revoke.
Install
npx shadcn add https://design.helpmarq.com /r/idler-drop.jsonSource
registry/core/idler-drop/component.tsx"use client";
import { useEffect, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// IdlerDrop — delegated admin drawn as a gear train. Your authority is a
// 24-tooth drive gear that turns continuously (one shared phase, 0.15 rev/s,
// applied identically to every row so meshes can never drift). Each delegate
// is a driven gear coupled through a swinging idler; its tooth count is fixed
// by role (admin 12, editor 8, viewer 3 — a discrete authority ratio, not a
// continuous radius) so the mesh, not a stored value, is what grants them
// anything. Revoke swings the idler carrier out over 250ms — the driven gear
// leaves the shared phase and freewheels down its own exponential (tau
// ~1.2s) while the drive gear keeps turning. That coast IS the disclosure:
// the delegate's existing session outlives the grant until it expires.
// Rotation is refs-only, imperative `setAttribute("transform", …)` on a
// single rAF loop; React state only touches the rare, discrete stuff (role,
// revoked, the coasting readout, the live region).
// ---------------------------------------------------------------------------
export type DelegateRole = "admin" | "editor" | "viewer";
export type Delegation = {
id: string;
name: string;
role: DelegateRole;
/** epoch ms the grant was made */
grantedAt: number;
/** epoch ms the delegate's underlying session token actually expires —
* real data the post-revoke "coasting" readout is computed from, not a
* scripted countdown. */
sessionExpiresAt: number;
};
type Row = Delegation & {
revoked: boolean;
coastingMinutes: number | null;
};
type Freewheel = { t0: number; angle0: number; v0: number };
const ROLE_TEETH: Record<DelegateRole, number> = {
admin: 12,
editor: 8,
viewer: 3,
};
const ROLE_LABEL: Record<DelegateRole, string> = {
admin: "Admin",
editor: "Editor",
viewer: "Viewer",
};
const ROLE_ORDER: DelegateRole[] = ["admin", "editor", "viewer"];
const DRIVE_TEETH = 24;
const IDLER_TEETH = 6; // cancels out of the final ratio — only sets direction + spacing
const DEG_PER_SEC = 0.15 * 360; // one shared train phase, 0.15 rev/s
const TAU_S = 1.2; // freewheel time constant
const SWING_MS = 250; // idler carrier disengage duration
const SWING_DEG = 40;
const MODULE = 2.6; // shared tooth pitch — what lets every gear actually mesh
const PAD = 18;
const ROW_H = 176;
const pitchR = (teeth: number) => teeth * MODULE;
const rootR = (rOuter: number) => rOuter * 0.62;
const boreR = (rInner: number) => rInner * 0.55;
const DRIVE_R = pitchR(DRIVE_TEETH);
const DRIVE_R_IN = rootR(DRIVE_R);
const DRIVE_CX = PAD + DRIVE_R;
const DRIVE_CY = ROW_H / 2;
const IDLER_R = pitchR(IDLER_TEETH);
const IDLER_R_IN = rootR(IDLER_R);
const ARM = DRIVE_R + IDLER_R; // idler's fixed distance from the drive centre
const IDLER_CX = DRIVE_CX + ARM;
const IDLER_CY = DRIVE_CY;
function drivenCx(role: DelegateRole) {
return IDLER_CX + IDLER_R + pitchR(ROLE_TEETH[role]);
}
const ROW_W = drivenCx("admin") + pitchR(ROLE_TEETH.admin) + PAD;
function gearPathAt(
cx: number,
cy: number,
teeth: number,
rOuter: number,
rInner: number
): string {
const step = 360 / teeth;
const half = step * 0.5;
const tipHalf = half * 0.5;
const toXY = (r: number, deg: number): [number, number] => {
const rad = ((deg - 90) * Math.PI) / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
};
const parts: string[] = [];
for (let i = 0; i < teeth; i++) {
const c = i * step;
const [x0, y0] = toXY(rInner, c - half);
const [x1, y1] = toXY(rOuter, c - tipHalf);
const [x2, y2] = toXY(rOuter, c + tipHalf);
const [x3, y3] = toXY(rInner, c + half);
parts.push(`${i === 0 ? "M" : "L"}${x0.toFixed(2)},${y0.toFixed(2)}`);
parts.push(`L${x1.toFixed(2)},${y1.toFixed(2)}`);
parts.push(`L${x2.toFixed(2)},${y2.toFixed(2)}`);
parts.push(`L${x3.toFixed(2)},${y3.toFixed(2)}`);
}
parts.push("Z");
return parts.join(" ");
}
// timing mark: a short radial tick from root to tip at angle 0, so a gear
// with perfectly regular teeth still reads as rotating rather than static.
function tickAt(cx: number, cy: number, rInner: number, rOuter: number) {
return { x1: cx, y1: cy - rInner, x2: cx, y2: cy - rOuter };
}
const DRIVE_PATH = gearPathAt(DRIVE_CX, DRIVE_CY, DRIVE_TEETH, DRIVE_R, DRIVE_R_IN);
const DRIVE_TICK = tickAt(DRIVE_CX, DRIVE_CY, DRIVE_R_IN, DRIVE_R);
const DRIVE_BORE = boreR(DRIVE_R_IN);
const IDLER_PATH = gearPathAt(IDLER_CX, IDLER_CY, IDLER_TEETH, IDLER_R, IDLER_R_IN);
const IDLER_TICK = tickAt(IDLER_CX, IDLER_CY, IDLER_R_IN, IDLER_R);
const IDLER_BORE = boreR(IDLER_R_IN);
const DRIVEN_GEOM = Object.fromEntries(
ROLE_ORDER.map((role) => {
const cx = drivenCx(role);
const r = pitchR(ROLE_TEETH[role]);
const rIn = rootR(r);
return [
role,
{
cx,
cy: DRIVE_CY,
r,
path: gearPathAt(cx, DRIVE_CY, ROLE_TEETH[role], r, rIn),
tick: tickAt(cx, DRIVE_CY, rIn, r),
bore: boreR(rIn),
},
];
})
) as Record<
DelegateRole,
{ cx: number; cy: number; r: number; path: string; tick: ReturnType<typeof tickAt>; bore: number }
>;
const MONTHS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
function shortDate(ms: number): string {
const d = new Date(ms);
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
}
function easeOutCubic(t: number): number {
const k = 1 - t;
return 1 - k * k * k;
}
export function IdlerDrop({
delegations,
title = "Delegated admin",
className = "",
}: {
delegations: Delegation[];
title?: string;
className?: string;
}) {
const [rows, setRows] = useState<Row[]>(() =>
delegations.map((d) => ({ ...d, revoked: false, coastingMinutes: null }))
);
const [liveMessage, setLiveMessage] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const rowsRef = useRef(rows);
rowsRef.current = rows;
const gRefs = useRef<
Record<string, { drive: SVGGElement | null; idler: SVGGElement | null; driven: SVGGElement | null }>
>({});
const phaseRef = useRef(0);
const freewheelRef = useRef<Record<string, Freewheel>>({});
const wakeRef = useRef<(() => void) | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const reducedRef = { current: mq.matches };
const onMqChange = () => {
reducedRef.current = mq.matches;
wakeRef.current?.();
};
mq.addEventListener("change", onMqChange);
let visible = true;
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible) wakeRef.current?.();
});
io.observe(root);
let raf = 0;
let last = 0;
const applyRow = (id: string, driveDeg: number, idlerAttr: string, drivenDeg: number) => {
const g = gRefs.current[id];
if (!g) return;
g.drive?.setAttribute("transform", `rotate(${driveDeg} ${DRIVE_CX} ${DRIVE_CY})`);
g.idler?.setAttribute("transform", idlerAttr);
const row = rowsRef.current.find((r) => r.id === id);
const cx = row ? DRIVEN_GEOM[row.role].cx : 0;
g.driven?.setAttribute("transform", `rotate(${drivenDeg} ${cx} ${DRIVE_CY})`);
};
const restFrame = () => {
for (const row of rowsRef.current) {
const swing = row.revoked ? SWING_DEG : 0;
const idlerAttr = `rotate(${swing} ${DRIVE_CX} ${DRIVE_CY}) rotate(0 ${IDLER_CX} ${IDLER_CY})`;
applyRow(row.id, 0, idlerAttr, 0);
}
};
const loop = (now: number) => {
raf = 0;
if (reducedRef.current) {
restFrame();
last = 0;
return; // sleeps until the media-query listener wakes it
}
if (!visible || document.hidden) {
last = 0;
return; // IntersectionObserver / visibilitychange wakes it
}
const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
last = now;
phaseRef.current += DEG_PER_SEC * dt;
const phase = phaseRef.current;
const idlerSpin = -phase * (DRIVE_TEETH / IDLER_TEETH);
for (const row of rowsRef.current) {
const fw = freewheelRef.current[row.id];
const swing = fw
? SWING_DEG * easeOutCubic(Math.min(1, ((now - fw.t0) / SWING_MS)))
: 0;
const idlerAttr = `rotate(${swing} ${DRIVE_CX} ${DRIVE_CY}) rotate(${idlerSpin} ${IDLER_CX} ${IDLER_CY})`;
let drivenDeg: number;
if (fw) {
const t = (now - fw.t0) / 1000;
drivenDeg = fw.angle0 + fw.v0 * TAU_S * (1 - Math.exp(-t / TAU_S));
} else {
drivenDeg = phase * (DRIVE_TEETH / ROLE_TEETH[row.role]);
}
applyRow(row.id, phase, idlerAttr, drivenDeg);
}
raf = requestAnimationFrame(loop);
};
const wake = () => {
if (!raf) {
last = 0;
raf = requestAnimationFrame(loop);
}
};
wakeRef.current = wake;
wake();
const onVis = () => {
if (!document.hidden) wake();
};
document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(raf);
io.disconnect();
mq.removeEventListener("change", onMqChange);
document.removeEventListener("visibilitychange", onVis);
wakeRef.current = null;
};
}, []);
function handleRevoke(id: string) {
const row = rowsRef.current.find((r) => r.id === id);
if (!row || row.revoked) return;
const teeth = ROLE_TEETH[row.role];
freewheelRef.current[id] = {
t0: performance.now(),
angle0: phaseRef.current * (DRIVE_TEETH / teeth),
v0: DEG_PER_SEC * (DRIVE_TEETH / teeth),
};
const mins = Math.max(0, Math.round((row.sessionExpiresAt - Date.now()) / 60000));
setRows((prev) =>
prev.map((r) => (r.id === id ? { ...r, revoked: true, coastingMinutes: mins } : r))
);
setLiveMessage(
`${ROLE_LABEL[row.role]} access revoked for ${row.name}; existing session expires in ${mins} minute${mins === 1 ? "" : "s"}.`
);
wakeRef.current?.();
}
function handleRoleChange(id: string, role: DelegateRole) {
setRows((prev) => prev.map((r) => (r.id === id && !r.revoked ? { ...r, role } : r)));
}
return (
<div ref={rootRef} className={`rounded-md border border-border bg-background ${className}`}>
<style>{`
.ns-idr-btn,.ns-idr-select{transition:opacity 150ms ease-out,border-color 150ms ease-out}
@media (prefers-reduced-motion: reduce){
.ns-idr-btn,.ns-idr-select{transition:none}
}
`}</style>
<div className="border-b border-border px-5 py-4">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<p className="mt-1 font-mono text-[11px] tracking-wide text-ns-muted">
drive 24T · admin 12T · editor 8T · viewer 3T
</p>
</div>
<div aria-live="polite" role="status" className="sr-only">
{liveMessage}
</div>
<ul role="list" className="divide-y divide-border">
{rows.map((row, i) => {
const geom = DRIVEN_GEOM[row.role];
const drivenLeft = (geom.cx / ROW_W) * 100;
const drivenTop = (geom.cy / ROW_H) * 100;
return (
<li
key={row.id}
data-row-index={i}
className="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center"
>
<div
className="relative w-full shrink-0 sm:w-[220px]"
style={{ aspectRatio: `${ROW_W} / ${ROW_H}` }}
>
<svg
viewBox={`0 0 ${ROW_W} ${ROW_H}`}
className="absolute inset-0 h-full w-full"
aria-hidden="true"
>
<g
ref={(el) => {
gRefs.current[row.id] ??= { drive: null, idler: null, driven: null };
gRefs.current[row.id].drive = el;
}}
transform={`rotate(0 ${DRIVE_CX} ${DRIVE_CY})`}
>
<path d={DRIVE_PATH} fill="none" stroke="var(--foreground)" strokeWidth={1.25} />
<circle cx={DRIVE_CX} cy={DRIVE_CY} r={DRIVE_BORE} fill="var(--background)" stroke="var(--border)" />
<line {...DRIVE_TICK} stroke="var(--foreground)" strokeWidth={1.25} />
</g>
<g
ref={(el) => {
gRefs.current[row.id] ??= { drive: null, idler: null, driven: null };
gRefs.current[row.id].idler = el;
}}
transform={`rotate(${row.revoked ? SWING_DEG : 0} ${DRIVE_CX} ${DRIVE_CY}) rotate(0 ${IDLER_CX} ${IDLER_CY})`}
>
<path d={IDLER_PATH} fill="none" stroke="var(--ns-muted)" strokeWidth={1} />
<circle cx={IDLER_CX} cy={IDLER_CY} r={IDLER_BORE} fill="var(--background)" stroke="var(--border)" />
<line {...IDLER_TICK} stroke="var(--ns-muted)" strokeWidth={1} />
</g>
<g
ref={(el) => {
gRefs.current[row.id] ??= { drive: null, idler: null, driven: null };
gRefs.current[row.id].driven = el;
}}
transform={`rotate(0 ${geom.cx} ${geom.cy})`}
>
<path d={geom.path} fill="none" stroke="var(--foreground)" strokeWidth={1.25} />
<circle cx={geom.cx} cy={geom.cy} r={geom.bore} fill="var(--background)" stroke="var(--border)" />
<line {...geom.tick} stroke="var(--foreground)" strokeWidth={1.25} />
</g>
</svg>
{/* Real DOM text, anchored at the driven gear's bore — never
part of the rotating group, so it stays upright and
legible while the ring around it turns or freewheels. */}
<div
className="pointer-events-none absolute select-none text-center leading-tight"
style={{
left: `${drivenLeft}%`,
top: `${drivenTop}%`,
transform: "translate(-50%, -50%)",
width: 64,
}}
>
<div className="truncate text-[9px] font-semibold text-foreground">{row.name}</div>
<div className="truncate font-mono text-[7.5px] text-ns-muted">
{ROLE_LABEL[row.role]} · {shortDate(row.grantedAt)}
</div>
</div>
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{row.name}
</span>
<label className="flex items-center gap-1.5">
<span className="sr-only">Role for {row.name}</span>
<select
value={row.role}
disabled={row.revoked}
onChange={(e) => handleRoleChange(row.id, e.target.value as DelegateRole)}
aria-label={`Role for ${row.name}`}
className="ns-idr-select rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent disabled:opacity-45"
>
{ROLE_ORDER.map((r) => (
<option key={r} value={r}>
{ROLE_LABEL[r]}
</option>
))}
</select>
</label>
<button
type="button"
data-revoke-btn
disabled={row.revoked}
onClick={() => handleRevoke(row.id)}
aria-label={row.revoked ? `Access revoked for ${row.name}` : `Revoke access for ${row.name}`}
className="ns-idr-btn rounded-sm border border-border px-2.5 py-1 text-xs font-medium text-foreground hover:border-foreground/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent disabled:opacity-45 disabled:hover:border-border"
>
{row.revoked ? "Revoked" : "Revoke"}
</button>
{row.revoked ? (
<span data-coasting className="font-mono text-[11px] text-ns-muted">
coasting: {row.coastingMinutes} min
</span>
) : null}
</div>
</li>
);
})}
</ul>
</div>
);
}
Build spec
`<IdlerDrop delegations={Delegation[]} title? className? />` where `Delegation` is `{ id, name, role: "admin"|"editor"|"viewer", grantedAt /* ms epoch */, sessionExpiresAt /* ms epoch — real data the post-revoke readout is computed from */ }`. Renders a `role="list"` of rows; each row is one gear train. MECHANISM: a single shared train phase (module-level DEG_PER_SEC = 0.15 rev/s * 360) is advanced once per animation frame and applied identically to every row's drive gear via `rotate(phase, cx, cy)` on an SVG `<g>` — one clock, so no row's mesh can ever drift out of phase with another's. The drive gear is fixed at 24 teeth. Each delegate's driven gear is fixed at ROLE_TEETH[role] — 12 for admin, 8 for editor, 3 for viewer — because authority comes in exactly three legal roles, not a continuum; a continuously variable radius would visually imply a fractional permission the system has no way to grant. Drive and driven share one tooth module (MODULE = 2.6px/tooth) so every pair of gears in the piece is drawn at a center-distance that actually equals the sum of their pitch radii — the geometry is a real (if stylized) mesh, not two overlapping circles. Between them sits a 6-tooth idler mounted on a carrier arm pivoted at the drive gear's own center, at a fixed radius (rDrive+rIdler) from that pivot — this is what keeps the idler permanently meshed with the drive gear regardless of carrier angle, spinning with it always: the idler is drawn as an extension of YOUR authority, not the delegate's. WHILE ENGAGED (carrier at rest, angle 0) the idler also touches the driven gear, so the driven gear's rotation is a pure function of the shared phase: `drivenDeg = phase * (24 / roleTeeth)` — same direction as the drive gear (two external meshes reverse direction twice), always faster for a smaller-teeth role, i.e. viewer's tiny gear visibly whirs fast while admin's larger gear turns slower — small-and-fast reads as thin/easily-spun authority, big-and-slow reads as weighty. REVOKE swings the carrier by 40° over 250ms (`easeOutCubic`, imperative rAF tween off the click's own timestamp, not a CSS transition — avoids SVG transform-origin inconsistencies) which visibly opens a gap between the idler and the now-stationary-relative driven gear; AT THE SAME INSTANT the driven gear itself leaves the shared-phase formula and starts an independent exponential decay: its angular velocity at the moment of revoke (`v0 = 54deg/s * 24/roleTeeth`) decays as `v0 * exp(-t/1.2s)`, integrated to `angle0 + v0*1.2*(1-exp(-t/1.2))` — so it keeps spinning, slowing continuously, settling over several seconds rather than snapping to a stop. That coast is deliberate: it is the visual disclosure that the delegate's existing session persists past the revoke until its own token actually expires. The drive gear never slows down, never pauses, regardless of how many rows are revoked — it is drawn from the same shared phase as every engaged row, always. IDENTITY TEXT: each driven gear's name / role / grant-date live as real DOM text in a small `<div>` absolutely positioned (as a percentage of the row's SVG viewBox, so it tracks correctly at any render width) at that gear's own center — deliberately OUTSIDE the rotating `<g>`, so the label stays upright and legible while the ring spins or freewheels around it; a thin punched bore circle (`fill: var(--background)`, `stroke: var(--border)`) sits on the rotating layer beneath it for the visual read of a hub. Every gear also carries one radial timing-tick line so a perfectly regular tooth ring still reads as turning frame to frame rather than looking static between tooth-pitch repeats. CONTROLS: each row has a real `<select>` for role (disabled once revoked — the grant is historical, not editable) that changes `ROLE_TEETH[role]` live, immediately re-deriving that row's mesh ratio and geometry; and a real `<button>` labelled Revoke that, once pressed, disables itself, relabels to "Revoked", and reveals a `data-coasting` Geist Mono readout — `coasting: 14 min` — computed once from `Math.round((sessionExpiresAt - Date.now()) / 60000)` against the delegate's actual session-expiry timestamp, not a scripted number. A11Y: the whole train SVG is `aria-hidden`; the identity text, role select and revoke button are the real semantic surface. A visually-hidden `role="status" aria-live="polite"` region speaks the coasting fact in full on every revoke — "Editor access revoked for Sam; existing session expires in 14 minutes." — never leaving the freewheel spin as the only signal. REDUCED MOTION: a `matchMedia("(prefers-reduced-motion: reduce)")` check (with a live `change` listener) makes the rAF loop apply one static rest frame instead of scheduling further frames — phase pinned at 0, every engaged driven gear at 0°, and any revoked row's idler carrier still snapped instantly to its swung-out 40° with no tween — so engaged rows read as statically meshed, revoked rows read as statically disengaged, and the coasting label is present and correct either way; only continuous rotation is removed. Colors are `var(--foreground)` for the drive and driven rings/ticks, `var(--ns-muted)` for the idler (the connector, not an authority holder itself) and identity subtext, `var(--border)`/`var(--background)` for the punched bores, and `var(--ns-accent)` solely as the `focus-visible` ring on the select and button — never a resting color. DOM + SVG + CSS only, no canvas, zero dependencies.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| delegations | Delegation[] | — | — |
| title? | string | "Delegated admin" | — |
| className? | string | — | — |