# Ratchet — Animated Button for React

> 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.

- Page: https://crucibleui.com/components/buttons/ratchet
- Category: [buttons](https://crucibleui.com/components/buttons)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/ratchet
```

Registry JSON: https://crucibleui.com/r/ratchet.json

Import from `@/components/crucible/ratchet`.

## Props (`RatchetProps`)

| 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: `Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, OwnedHandlers>`.

## Usage example

```tsx
"use client";

import { Ratchet } from "@/components/crucible/ratchet";

const LABEL = "text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600";

export default function RatchetDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex flex-col items-center gap-10 px-4">
        <p className="text-sm text-neutral-400">Press and hold.</p>
        <div className="flex flex-wrap items-start justify-center gap-10">
          <div className="flex flex-col items-center gap-3">
            <Ratchet tone="danger" confirmedLabel="Project deleted">
              Delete project
            </Ratchet>
            <span className={LABEL}>Danger</span>
          </div>
          <div className="flex flex-col items-center gap-3">
            <Ratchet tone="neutral" confirmedLabel="Published">
              Publish
            </Ratchet>
            <span className={LABEL}>Neutral</span>
          </div>
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`ratchet.tsx`)

```tsx
"use client";

import { AnimatePresence, motion, useInView } from "motion/react";
import * as React from "react";

import { useReducedMotion } from "@/hooks/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>
    </>
  );
}
```
