Ratchet
Hold-to-confirm button for destructive actions: pressing and holding (pointer or Space/Enter) advances a 24-tooth SVG ring tooth by tooth with a click-scale, releasing early ratchets it back down faster, and completion flashes the ring, morphs the icon into a check and fires onConfirm. Real button, aria-describedby hold hint, polite live announcement, stepped-opacity fill under reduced motion.
motionfree
"use client";
import { AnimatePresence, motion, useInView } from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export type RatchetTone = "danger" | "neutral";
export interface RatchetHandle {
/** Return to the idle state immediately (clears a latched confirmation). */
reset(): void;
}
type OwnedHandlers =
| "children"
| "onClick"
| "onPointerDown"
| "onPointerUp"
| "onPointerMove"
| "onPointerCancel"
| "onLostPointerCapture"
| "onKeyDown"
| "onKeyUp"
| "onBlur"
| "onContextMenu";
export interface RatchetProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, OwnedHandlers> {
/** Idle label, shown to the right of the toothed ring. @default "Hold to confirm" */
children?: React.ReactNode;
/** Label shown (and politely announced) after the hold completes. @default "Confirmed" */
confirmedLabel?: React.ReactNode;
/** Milliseconds of continuous holding needed to fill all 24 teeth. @default 1200 */
holdMs?: number;
/** Fires once when the ring completes. Never fires on an early release. */
onConfirm?: () => void;
/** Stay in the confirmed state instead of resetting after 1.6s (call `ref.reset()` to clear). @default false */
latch?: boolean;
/** Accent family: `danger` is rust, `neutral` is graphite. @default "danger" */
tone?: RatchetTone;
/** Override the tone's accent color (any CSS color). */
accentColor?: string;
/** Icon inside the well. @default a trash glyph (danger) or an up-arrow (neutral) */
icon?: React.ReactNode;
/** Really disable the button. @default false */
disabled?: boolean;
/** Imperative handle: `{ reset() }`. */
ref?: React.Ref<RatchetHandle>;
}
const TEETH = 24;
const DRAIN_SPEED = 2.6; // release ratchets down this many times faster than it went up
const CONFIRM_DWELL = 1600;
const TONE_ACCENT: Record<RatchetTone, string> = {
danger: "#e2683f", // rust
neutral: "#d4d4d8", // graphite (lit zinc on dark)
};
// One tooth, drawn at 12 o'clock in a 100×100 box: a trapezoid wider at its root.
const TOOTH_PATH = (() => {
const cx = 50;
const cy = 50;
const pt = (r: number, deg: number) => {
const a = ((deg - 90) * Math.PI) / 180;
return `${(cx + r * Math.cos(a)).toFixed(3)} ${(cy + r * Math.sin(a)).toFixed(3)}`;
};
const rRoot = 35;
const rTip = 48.5;
return `M${pt(rRoot, -5.4)} L${pt(rTip, -3.6)} L${pt(rTip, 3.6)} L${pt(rRoot, 5.4)} Z`;
})();
function TrashGlyph() {
return (
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" aria-hidden>
<path
d="M4.5 6.5h15M9.5 6.5V4.75c0-.41.34-.75.75-.75h3.5c.41 0 .75.34.75.75V6.5M6.5 6.5l.85 12.1c.06.8.72 1.4 1.5 1.4h6.3c.78 0 1.44-.6 1.5-1.4l.85-12.1M10.25 10.5v6M13.75 10.5v6"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ArrowGlyph() {
return (
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" aria-hidden>
<path
d="M12 19V5.5M6.5 11 12 5.5 17.5 11"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/**
* Ratchet — a hold-to-confirm button for destructive or irreversible actions.
* A toothed ring (24 teeth) wraps a circular icon well; pressing and holding
* (pointer, or Space/Enter held — key auto-repeat ignored) advances the ring
* tooth by tooth, each tooth landing with a tiny click-scale. Let go early
* and it ratchets back down, faster than it went up. On completion the ring
* flashes once, the icon morphs into a check, `onConfirm` fires and the label
* swaps to `confirmedLabel` for 1.6s (or stays, with `latch`). A real
* `<button>` with an `aria-describedby` hold hint and a polite completion
* announcement; under reduced motion the fill advances in stepped opacity
* with no scale.
*/
export function Ratchet({
children = "Hold to confirm",
confirmedLabel = "Confirmed",
holdMs = 1200,
onConfirm,
latch = false,
tone = "danger",
accentColor,
icon,
disabled = false,
className,
style,
ref,
type = "button",
...rest
}: RatchetProps) {
const reduced = useReducedMotion();
const accent = accentColor ?? TONE_ACCENT[tone];
const uid = React.useId();
const hintId = `${uid}-hint`;
const glowId = `${uid.replace(/:/g, "")}-glow`;
const btnRef = React.useRef<HTMLButtonElement>(null);
const inView = useInView(btnRef, { once: true, amount: 0.4 });
const [lit, setLit] = React.useState(0);
const [holding, setHolding] = React.useState(false);
const [confirmed, setConfirmed] = React.useState(false);
const [flashKey, setFlashKey] = React.useState(0);
const progressRef = React.useRef(0); // in teeth units, 0..TEETH
const holdingRef = React.useRef(false);
const confirmedRef = React.useRef(false);
const rafRef = React.useRef(0);
const lastRef = React.useRef(0);
const timerRef = React.useRef(0);
const holdMsRef = React.useRef(holdMs);
const onConfirmRef = React.useRef(onConfirm);
const latchRef = React.useRef(latch);
const reducedRef = React.useRef(reduced);
React.useEffect(() => {
holdMsRef.current = holdMs;
onConfirmRef.current = onConfirm;
latchRef.current = latch;
reducedRef.current = reduced;
});
const stopLoop = React.useCallback(() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
}, []);
const loopRef = React.useRef<(t: number) => void>(() => {});
const ensureLoop = React.useCallback(() => {
if (rafRef.current) return;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame((t) => loopRef.current(t));
}, []);
const complete = React.useCallback(() => {
holdingRef.current = false;
confirmedRef.current = true;
setHolding(false);
setConfirmed(true);
setFlashKey((k) => k + 1);
onConfirmRef.current?.();
window.clearTimeout(timerRef.current);
if (!latchRef.current) {
timerRef.current = window.setTimeout(() => {
confirmedRef.current = false;
setConfirmed(false);
// Ratchet the full ring back down so the reset reads as mechanical too.
if (reducedRef.current) {
progressRef.current = 0;
setLit(0);
} else ensureLoop();
}, CONFIRM_DWELL);
}
}, [ensureLoop]);
React.useEffect(() => {
loopRef.current = (t: number) => {
// Clamp dt so a backgrounded tab can't complete a hold in one frame.
const dt = Math.min(50, t - lastRef.current);
lastRef.current = t;
const rate = TEETH / Math.max(1, holdMsRef.current); // teeth per ms
let p = progressRef.current;
if (holdingRef.current) p = Math.min(TEETH, p + dt * rate);
else if (!confirmedRef.current) p = Math.max(0, p - dt * rate * DRAIN_SPEED);
progressRef.current = p;
const n = Math.floor(p + 1e-6);
setLit(n);
if (holdingRef.current && p >= TEETH) {
rafRef.current = 0;
complete();
return;
}
if (!holdingRef.current && (p <= 0 || confirmedRef.current)) {
rafRef.current = 0;
return;
}
rafRef.current = requestAnimationFrame((tt) => loopRef.current(tt));
};
}, [complete]);
React.useEffect(
() => () => {
stopLoop();
window.clearTimeout(timerRef.current);
},
[stopLoop]
);
const startHold = React.useCallback(() => {
if (disabled || confirmedRef.current || holdingRef.current) return;
holdingRef.current = true;
setHolding(true);
ensureLoop();
}, [disabled, ensureLoop]);
const cancelHold = React.useCallback(() => {
if (!holdingRef.current) return;
holdingRef.current = false;
setHolding(false);
if (reducedRef.current) {
stopLoop();
progressRef.current = 0;
setLit(0);
} else ensureLoop();
}, [ensureLoop, stopLoop]);
const reset = React.useCallback(() => {
stopLoop();
window.clearTimeout(timerRef.current);
holdingRef.current = false;
confirmedRef.current = false;
progressRef.current = 0;
setHolding(false);
setConfirmed(false);
setLit(0);
}, [stopLoop]);
React.useImperativeHandle(ref, () => ({ reset }), [reset]);
React.useEffect(() => {
if (disabled) cancelHold();
}, [disabled, cancelHold]);
// Reduced motion: the fill advances in four stepped-opacity quarters.
const shownLit = reduced ? Math.floor(lit / (TEETH / 4)) * (TEETH / 4) : lit;
const fill = confirmed ? 1 : shownLit / TEETH;
const introShown = reduced || inView;
const isKey = (k: string) => k === " " || k === "Enter";
return (
<>
<button
ref={btnRef}
type={type}
data-crucible="ratchet"
data-tone={tone}
data-holding={holding || undefined}
data-confirmed={confirmed || undefined}
disabled={disabled}
aria-describedby={hintId}
aria-disabled={confirmed || undefined}
className={cn(
"group/ratchet relative isolate inline-flex h-14 select-none items-center gap-3 rounded-full",
"border border-neutral-800 bg-neutral-900 py-1 pl-1 pr-6 text-sm font-medium text-neutral-100",
"touch-manipulation [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent]",
"transition-[border-color,background-color,transform] duration-200 ease-out",
"hover:border-neutral-700 hover:bg-[#1c1c1c]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
"disabled:cursor-not-allowed disabled:opacity-50",
confirmed ? "cursor-default" : "cursor-pointer",
holding && !reduced && "scale-[0.985]",
className
)}
style={
{
"--ratchet-accent": accent,
borderColor:
holding || confirmed
? `color-mix(in oklab, ${accent} ${confirmed ? 45 : 20 + fill * 30}%, #262626)`
: undefined,
...style,
} as React.CSSProperties
}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
startHold();
}}
onPointerMove={(e) => {
if (!holdingRef.current) return;
const r = e.currentTarget.getBoundingClientRect();
const slop = 12;
if (
e.clientX < r.left - slop ||
e.clientX > r.right + slop ||
e.clientY < r.top - slop ||
e.clientY > r.bottom + slop
)
cancelHold();
}}
onPointerUp={cancelHold}
onPointerCancel={cancelHold}
onLostPointerCapture={cancelHold}
onContextMenu={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelHold();
return;
}
if (!isKey(e.key)) return;
e.preventDefault();
if (e.repeat) return;
startHold();
}}
onKeyUp={(e) => {
if (!isKey(e.key)) return;
e.preventDefault();
cancelHold();
}}
onBlur={cancelHold}
{...rest}
>
{/* Face light + an accent wash that rises with the fill. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 rounded-[inherit]"
style={{
background:
"radial-gradient(120% 90% at 50% 0%, rgba(255,255,255,0.06), transparent 55%)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), inset 0 -1px 0 rgba(0,0,0,0.35)",
}}
/>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 rounded-[inherit] transition-opacity duration-150"
style={{
background: `radial-gradient(90% 160% at 0% 50%, color-mix(in oklab, ${accent} 26%, transparent), transparent 70%)`,
opacity: confirmed ? 0.8 : fill * 0.9,
}}
/>
{/* Toothed ring around the icon well. */}
<span aria-hidden className="relative block size-12 shrink-0">
<svg viewBox="0 0 100 100" className="absolute inset-0 size-full overflow-visible">
<defs>
<filter id={glowId} x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="2.4" />
</filter>
</defs>
{/* Glow layer: only lit teeth, blurred. */}
<g filter={`url(#${glowId})`} opacity={confirmed ? 0.9 : 0.7}>
{Array.from({ length: TEETH }, (_, i) => (
<path
key={i}
d={TOOTH_PATH}
transform={`rotate(${i * (360 / TEETH)} 50 50)`}
fill={accent}
style={{
opacity: confirmed || i < shownLit ? 1 : 0,
transition: "opacity 120ms ease-out",
}}
/>
))}
</g>
{Array.from({ length: TEETH }, (_, i) => {
const on = confirmed || i < shownLit;
const leading = !on && i === shownLit && !confirmed;
return (
<g key={i} transform={`rotate(${i * (360 / TEETH)} 50 50)`}>
<motion.path
d={TOOTH_PATH}
fill={accent}
className={cn(
!on && "[fill:rgba(255,255,255,0.11)]",
leading &&
i === 0 &&
"group-hover/ratchet:[fill:color-mix(in_oklab,var(--ratchet-accent)_45%,#3f3f46)] group-focus-visible/ratchet:[fill:color-mix(in_oklab,var(--ratchet-accent)_45%,#3f3f46)]"
)}
style={{
transformBox: "fill-box",
transformOrigin: "50% 50%",
transition: "fill 110ms ease-out",
}}
initial={reduced ? false : { opacity: 0, scale: 0.6 }}
animate={{
opacity: introShown ? 1 : 0,
scale: on && !reduced && !confirmed ? [1.55, 1] : 1,
}}
transition={
on && !reduced && !confirmed
? { scale: { duration: 0.18, ease: "easeOut" } }
: {
opacity: { duration: 0.3, delay: reduced ? 0 : i * 0.018 },
scale: { duration: 0.3, delay: reduced ? 0 : i * 0.018 },
}
}
/>
</g>
);
})}
{/* Completion flash: one ring of light that expands and fades. */}
{!reduced && flashKey > 0 ? (
<motion.circle
key={flashKey}
cx={50}
cy={50}
r={42}
fill="none"
stroke={accent}
strokeWidth={3}
style={{ transformBox: "fill-box", transformOrigin: "50% 50%" }}
initial={{ opacity: 0.95, scale: 1 }}
animate={{ opacity: 0, scale: 1.28 }}
transition={{ duration: 0.6, ease: [0.2, 0.7, 0.3, 1] }}
/>
) : null}
</svg>
{/* Icon well. */}
<span
className="absolute inset-[9px] flex items-center justify-center rounded-full border border-white/[0.07] bg-neutral-950 shadow-[inset_0_1px_3px_rgba(0,0,0,0.6)]"
style={{
boxShadow: confirmed
? `inset 0 0 10px -2px color-mix(in oklab, ${accent} 55%, transparent)`
: undefined,
}}
>
<AnimatePresence mode="popLayout" initial={false}>
{confirmed ? (
<motion.span
key="check"
className="block size-[15px]"
style={{ color: accent }}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6, rotate: -30 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={reduced ? { duration: 0.15 } : { type: "spring", stiffness: 520, damping: 26 }}
>
<svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" aria-hidden>
<motion.path
d="M5 12.5 10 17.5 19 7"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduced ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1], delay: 0.05 }}
/>
</svg>
</motion.span>
) : (
<motion.span
key="icon"
className="block size-[15px] transition-colors duration-150"
style={{
color: fill > 0 ? `color-mix(in oklab, ${accent} ${30 + fill * 70}%, #a3a3a3)` : "#a3a3a3",
}}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduced ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.4, rotate: 25, transition: { duration: 0.14 } }}
transition={reduced ? { duration: 0.15 } : { type: "spring", stiffness: 420, damping: 28 }}
>
{icon ?? (tone === "danger" ? <TrashGlyph /> : <ArrowGlyph />)}
</motion.span>
)}
</AnimatePresence>
</span>
</span>
{/* Labels share one grid cell so the width never jumps. */}
<span className="relative grid overflow-hidden whitespace-nowrap py-1">
<motion.span
aria-hidden={confirmed || undefined}
className="col-start-1 row-start-1"
initial={false}
animate={reduced ? { opacity: confirmed ? 0 : 1 } : { opacity: confirmed ? 0 : 1, y: confirmed ? "-110%" : "0%" }}
transition={reduced ? { duration: 0.15 } : { y: { type: "spring", stiffness: 480, damping: 34 }, opacity: { duration: 0.14 } }}
>
{children}
</motion.span>
<motion.span
aria-hidden={!confirmed || undefined}
className="col-start-1 row-start-1"
style={{ color: `color-mix(in oklab, ${accent} 35%, #fafafa)` }}
initial={false}
animate={reduced ? { opacity: confirmed ? 1 : 0 } : { opacity: confirmed ? 1 : 0, y: confirmed ? "0%" : "110%" }}
transition={reduced ? { duration: 0.15 } : { y: { type: "spring", stiffness: 480, damping: 34 }, opacity: { duration: 0.14 } }}
>
{confirmedLabel}
</motion.span>
</span>
</button>
<span id={hintId} className="sr-only">
Press and hold to confirm
</span>
<span role="status" aria-live="polite" className="sr-only">
{confirmed ? confirmedLabel : null}
</span>
</>
);
}
Installation
CLI
npx shadcn@latest add @crucible/ratchetManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | "Hold to confirm" | Idle label, shown to the right of the toothed ring. |
| confirmedLabel | React.ReactNode | "Confirmed" | Label shown (and politely announced) after the hold completes. |
| holdMs | number | 1200 | Milliseconds of continuous holding needed to fill all 24 teeth. |
| onConfirm | () => void | Fires once when the ring completes. Never fires on an early release. | |
| latch | boolean | false | Stay in the confirmed state instead of resetting after 1.6s (call ref.reset() to clear). |
| tone | RatchetTone | "danger" | Accent family: danger is rust, neutral is graphite. |
| accentColor | string | Override the tone's accent color (any CSS color). | |
| icon | React.ReactNode | a trash glyph (danger) or an up-arrow (neutral) | Icon inside the well. |
| disabled | boolean | false | Really disable the button. |
| ref | React.Ref<RatchetHandle> | Imperative handle: { reset() }. | |
Also accepts all props of Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, OwnedHandlers> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.