A retry button that visibly winds a torsion spring through its backoff — disabled and charging while a --foreground arc grows on real timing, notching every failure, pressable only once fully wound.
npx shadcn add https://design.helpmarq.com /r/button-retry-backoff.jsonregistry/core/button-retry-backoff/component.tsx"use client";
import { useEffect, useId, useRef, useState } from "react";
// TorsionRetry — a retry button that is honest about its rate limit. Idle and
// charged are the only pressable states; a failure winds a torsion spring: a
// 1.5px --foreground arc grows 0->360deg around the icon's --border track on
// LINEAR timing matched exactly to the real backoff delay (no fake easing —
// the visual duration IS the enforced cooldown), while the refresh glyph
// rotates slowly backward as if under tension. Reaching full wind snaps the
// button to "charged": the icon pops with one damped-spring overshoot, the
// glyph un-tenses 5deg forward (a permanent correction, not a bounce-back),
// and the button gains --accent text/border as its one legitimate accent use.
// Every failure also drops a permanent 2px --muted notch tick at a fixed
// angular slot on the ring (1st failure always the same slot, 2nd always the
// next, etc.) — a live history of the episode, not just its current backoff.
// A successful retry resolves the episode: notches and attempt count reset,
// the ring goes fully quiet. Hot-path values (arc offset, glyph rotation,
// settle-spring) are written directly to refs every frame; React state only
// carries status/attempt/caption, so re-renders stay coarse. Under
// prefers-reduced-motion the arc advances in discrete steps with no glyph
// rotation and no overshoot. Zero dependencies, DOM + SVG + CSS only.
export interface TorsionRetryProps {
/** called when the user presses Retry once idle/charged; resolve/return
* false (or throw) to report failure, anything else counts as success */
onRetry?: () => Promise<boolean> | boolean;
/** wind duration for the first failure, ms (default 4000 — a real cooldown) */
baseDelayMs?: number;
/** multiplier applied per additional consecutive failure (default 2) */
factor?: number;
/** upper bound on wind duration regardless of attempt count (default 60000) */
maxDelayMs?: number;
/** fixed angular slots the ring can notch before saturating (default 8) —
* the attempt count in the text description is never capped, only the ring */
maxNotches?: number;
/** button label in the idle/charged state (default "Retry") */
label?: string;
className?: string;
}
type Status = "idle" | "checking" | "winding" | "charged";
const SIZE = 28;
const CENTER = SIZE / 2;
const TRACK_R = 10.5;
const CIRCUMFERENCE = 2 * Math.PI * TRACK_R;
const NOTCH_INNER = 11.25;
const NOTCH_OUTER = 13.25;
const ROTATE_DEG_PER_SEC = 26; // slow backward tension while winding
const REDUCED_STEPS = 10; // discrete arc steps under prefers-reduced-motion
const DT_MAX = 0.05; // s — clamp tab-switch/frame-stall jumps
// settle spring (icon pop + glyph un-tense), same recipe family as
// confirm-hold-ink's proven "pop" constants — underdamped, one visible bounce
const SETTLE_K = 300;
const SETTLE_C = 12;
function computeDelay(attempt: number, base: number, factor: number, max: number) {
return Math.min(max, base * Math.pow(factor, Math.max(0, attempt - 1)));
}
function notchAngleDeg(i: number, count: number) {
return (i + 0.5) * (360 / count);
}
function RefreshGlyph() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path
d="M11.5 4.2A5 5 0 1 0 12.4 8.1"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
<path
d="M11.6 1.6v3h-3"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function TorsionRetry({
onRetry,
baseDelayMs = 4000,
factor = 2,
maxDelayMs = 60000,
maxNotches = 8,
label = "Retry",
className = "",
}: TorsionRetryProps) {
const btnRef = useRef<HTMLButtonElement>(null);
const arcRef = useRef<SVGCircleElement>(null);
const iconRef = useRef<HTMLSpanElement>(null);
const glyphRef = useRef<HTMLSpanElement>(null);
const descId = useId();
const [status, setStatus] = useState<Status>("idle");
const [attempt, setAttempt] = useState(0);
const [caption, setCaption] = useState("Ready");
const onRetryRef = useRef(onRetry);
onRetryRef.current = onRetry;
const cfgRef = useRef({ baseDelayMs, factor, maxDelayMs });
cfgRef.current = { baseDelayMs, factor, maxDelayMs };
// hot-path state — refs only, never triggers a render
const s = useRef({
status: "idle" as Status,
attempt: 0,
delay: 0,
windStart: 0,
lastFrame: 0,
glyphDeg: 0, // accumulated rotation, direct-written to glyphRef
reduced: false,
lastStep: -1,
lastAnnouncedSec: -1,
raf: 0,
pausedAt: 0,
// settle spring — icon scale + glyph nudge, run once per charge
settling: false,
scaleV: 1,
scaleVel: 0,
nudgeV: 0,
nudgeVel: 0,
}).current;
useEffect(() => {
const btn = btnRef.current;
const arc = arcRef.current;
const icon = iconRef.current;
const glyph = glyphRef.current;
if (!btn || !arc || !icon || !glyph) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
s.reduced = mq.matches;
const onMq = () => {
s.reduced = mq.matches;
};
mq.addEventListener("change", onMq);
const applyGlyph = (deg: number) => {
glyph.style.transform = s.reduced ? "" : `rotate(${deg}deg)`;
};
const applyArc = (progress: number) => {
arc.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - progress));
};
const applyIconScale = (scale: number) => {
icon.style.transform = scale === 1 ? "" : `scale(${scale})`;
};
const secondsCaption = (remainingMs: number, attemptNum: number) => {
const secs = Math.max(0, Math.ceil(remainingMs / 1000));
return `Retry available in ${secs} second${secs === 1 ? "" : "s"}, attempt ${attemptNum}`;
};
const stopRaf = () => {
if (s.raf) cancelAnimationFrame(s.raf);
s.raf = 0;
};
const settleTick = (now: number) => {
const dt = Math.min(DT_MAX, (now - s.lastFrame) / 1000);
s.lastFrame = now;
s.scaleVel += ((1 - s.scaleV) * SETTLE_K - SETTLE_C * s.scaleVel) * dt;
s.scaleV += s.scaleVel * dt;
s.nudgeVel += ((5 - s.nudgeV) * SETTLE_K - SETTLE_C * s.nudgeVel) * dt;
s.nudgeV += s.nudgeVel * dt;
applyIconScale(s.reduced ? 1 : s.scaleV);
applyGlyph(s.glyphDeg + s.nudgeV);
const done =
Math.abs(s.scaleV - 1) < 0.001 &&
Math.abs(s.scaleVel) < 0.01 &&
Math.abs(s.nudgeV - 5) < 0.05 &&
Math.abs(s.nudgeVel) < 0.05;
if (done || s.reduced) {
s.scaleV = 1;
s.nudgeV = 5;
s.glyphDeg += 5;
applyIconScale(1);
applyGlyph(s.glyphDeg);
s.settling = false;
s.raf = 0;
return;
}
s.raf = requestAnimationFrame(settleTick);
};
const startSettle = () => {
s.settling = true;
s.scaleV = 0.97;
s.scaleVel = 0.6;
s.nudgeV = 0;
s.nudgeVel = 40; // deg/s kick — matches the spring's own timescale
s.lastFrame = performance.now();
stopRaf();
s.raf = requestAnimationFrame(settleTick);
};
const windTick = (now: number) => {
const elapsed = now - s.windStart;
const dt = Math.min(DT_MAX, (now - s.lastFrame) / 1000);
s.lastFrame = now;
const progress = Math.min(1, s.delay > 0 ? elapsed / s.delay : 1);
if (s.reduced) {
const step = Math.min(REDUCED_STEPS, Math.floor(progress * REDUCED_STEPS));
if (step !== s.lastStep) {
s.lastStep = step;
applyArc(step / REDUCED_STEPS);
}
} else {
applyArc(progress);
s.glyphDeg -= ROTATE_DEG_PER_SEC * dt;
applyGlyph(s.glyphDeg);
}
const remaining = Math.max(0, s.delay - elapsed);
const sec = Math.ceil(remaining / 1000);
if (sec !== s.lastAnnouncedSec) {
s.lastAnnouncedSec = sec;
setCaption(secondsCaption(remaining, s.attempt));
}
if (progress >= 1) {
s.status = "charged";
setStatus("charged");
setCaption("Retry available now");
applyArc(1);
s.raf = 0;
if (s.reduced) {
s.glyphDeg += 5;
applyGlyph(s.glyphDeg);
} else {
startSettle();
}
return;
}
s.raf = requestAnimationFrame(windTick);
};
const startWind = (attemptNum: number) => {
const { baseDelayMs: b, factor: f, maxDelayMs: m } = cfgRef.current;
const delay = computeDelay(attemptNum, b, f, m);
s.attempt = attemptNum;
s.delay = delay;
s.windStart = performance.now();
s.lastFrame = s.windStart;
s.lastStep = -1;
s.lastAnnouncedSec = -1;
s.status = "winding";
setStatus("winding");
applyArc(0);
stopRaf();
s.raf = requestAnimationFrame(windTick);
};
const handleClick = () => {
if (s.status !== "idle" && s.status !== "charged") return;
stopRaf();
s.status = "checking";
setStatus("checking");
setCaption("Retrying…");
Promise.resolve()
.then(() => onRetryRef.current?.())
.then(
(ok) => {
if (ok !== false) {
s.attempt = 0;
setAttempt(0);
s.status = "idle";
setStatus("idle");
setCaption("Ready");
applyArc(0);
} else {
const next = s.attempt + 1;
setAttempt(next);
startWind(next);
}
},
() => {
const next = s.attempt + 1;
setAttempt(next);
startWind(next);
}
);
};
btn.addEventListener("click", handleClick);
// pause the wind clock while the tab is hidden — resume without losing
// the elapsed progress by shifting windStart forward by the paused span
const onVisibility = () => {
if (document.hidden) {
if (s.raf) {
stopRaf();
s.pausedAt = performance.now();
}
} else if (s.pausedAt) {
const gap = performance.now() - s.pausedAt;
s.pausedAt = 0;
if (s.status === "winding") {
s.windStart += gap;
s.lastFrame = performance.now();
s.raf = requestAnimationFrame(windTick);
} else if (s.settling) {
s.lastFrame = performance.now();
s.raf = requestAnimationFrame(settleTick);
}
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
btn.removeEventListener("click", handleClick);
document.removeEventListener("visibilitychange", onVisibility);
mq.removeEventListener("change", onMq);
stopRaf();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const notchCount = Math.min(attempt, maxNotches);
const busy = status === "checking" || status === "winding";
return (
<div className={`inline-flex flex-col items-start gap-1.5 ${className}`}>
<button
ref={btnRef}
type="button"
data-torsion-retry
disabled={busy}
aria-describedby={descId}
className={[
"inline-flex items-center gap-2 rounded-sm border px-3 py-1.5 text-sm font-medium",
"border-border bg-background text-foreground",
"hover:border-muted hover:bg-border/60",
"disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-background",
"transition-colors duration-150 motion-reduce:transition-none",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
status === "charged" ? "border-accent text-accent" : "",
].join(" ")}
>
<span
ref={iconRef}
className="relative inline-flex h-7 w-7 shrink-0 items-center justify-center"
>
<svg
width={SIZE}
height={SIZE}
viewBox={`0 0 ${SIZE} ${SIZE}`}
aria-hidden="true"
className="absolute inset-0"
>
<g transform={`rotate(-90 ${CENTER} ${CENTER})`}>
<circle
cx={CENTER}
cy={CENTER}
r={TRACK_R}
fill="none"
stroke="var(--border)"
strokeWidth={1.5}
/>
{Array.from({ length: notchCount }, (_, i) => {
const rad = (notchAngleDeg(i, maxNotches) * Math.PI) / 180;
const x1 = CENTER + Math.cos(rad) * NOTCH_INNER;
const y1 = CENTER + Math.sin(rad) * NOTCH_INNER;
const x2 = CENTER + Math.cos(rad) * NOTCH_OUTER;
const y2 = CENTER + Math.sin(rad) * NOTCH_OUTER;
return (
<line
key={i}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="var(--muted)"
strokeWidth={2}
strokeLinecap="round"
/>
);
})}
<circle
ref={arcRef}
cx={CENTER}
cy={CENTER}
r={TRACK_R}
fill="none"
stroke="var(--foreground)"
strokeWidth={1.5}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={CIRCUMFERENCE}
/>
</g>
</svg>
<span ref={glyphRef} className="relative flex text-foreground">
<RefreshGlyph />
</span>
</span>
<span>{label}</span>
</button>
<p
id={descId}
aria-live="polite"
aria-atomic="true"
className="min-h-[1em] font-mono text-[11px] leading-tight text-muted"
>
{busy ? <span data-torsion-armed>{caption}</span> : caption}
</p>
</div>
);
}
A retry button that refuses to be the dishonest always-enabled kind: it is a real <button>, genuinely disabled while backing off, wired to an onRetry callback that returns/resolves false (or throws) to report failure. On failure a 1.5px --foreground arc grows from 0 to 360 degrees around an inner icon's --border track on LINEAR timing matched exactly to the real computed backoff delay (baseDelayMs * factor ^ (attempt-1), capped at maxDelayMs) — the animation duration IS the enforced cooldown, not a decorative approximation of it — while the refresh glyph inside rotates slowly backward as if winding under tension. The button only becomes pressable again once the wind completes: at that instant the icon pops with one damped-spring overshoot and the glyph un-tenses forward by a permanent 5 degrees (a correction, not a bounce back to where it started), and the button gains --accent text and border as its one legitimate accent use, signaling 'ready.' Every failure also drops a permanent 2px --muted tick at a fixed angular slot on the ring — the 1st failure always the same slot, the 2nd always the next — so the ring reads as a history of the current error episode, not just a countdown; the ring saturates at maxNotches (default 8) but the textual attempt count never does. A successful retry resolves the episode: notches and attempt count reset to zero and the ring goes fully quiet. Accessibility: aria-describedby points at a polite, atomic live region that is also rendered as visible text (all ring state exists as text, not just pixels) and only updates at coarse boundaries — once per second while winding ('Retry available in N seconds, attempt K'), once when checking ('Retrying…'), and once, distinctly, the instant it charges ('Retry available now') so the enabled state is announced exactly once rather than on every frame; focus is never stolen programmatically. Hot-path values (arc offset, glyph rotation, the settle spring) are written directly to refs every animation frame; React state only carries status/attempt/caption so re-renders stay coarse. Under prefers-reduced-motion the arc advances in discrete steps with no glyph rotation and no overshoot, but the backoff and notch history stay fully legible as text. Differs from a countdown readout by charging a mechanism toward readiness (an increasing arc, a state the button gates on) rather than depleting a displayed number, and by being an interactive control that accumulates failure history rather than a passive readout of remaining time. Zero dependencies, DOM + SVG + CSS only — no canvas.