Stepper Ratchet

Stepper

Numeric spinbutton built as a ratchet and pawl: incrementing is free and clicky and a hold repeats and accelerates, but decrementing requires holding ~250ms while the pawl visibly swings 35 degrees clear before a hold starts stepping back, also accelerating.

Install
npx shadcn add https://design.helpmarq.com/r/stepper-ratchet.json
Source
registry/core/stepper-ratchet/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// PawlLift — a bounded numeric spinbutton rendered as a ratchet-and-pawl with
// DIRECTION-DEPENDENT friction. Incrementing is the ratchet's free direction:
// a press advances the toothed rack one detent (ease-out-expo translateX)
// and the pawl gives a quick 80ms kick-and-reseat as it rides over the new
// tooth; holding the button repeats that same step-and-kick, accelerating
// from 400ms up to 60ms between steps. Decrementing runs against the
// ratchet: holding the decrement control rotates the pawl 35deg clear over
// 250ms before anything moves; only once armed does the rack start stepping
// backward, on the same 400ms-to-60ms accelerating schedule for as long as
// the hold continues; releasing at any point (pointerup, pointercancel,
// pointerleave, or blur) clears the repeat and snaps the pawl home on a
// spring curve. Both directions hold with mouse and touch alike (pointer
// events, not mouse-specific ones). The 250ms arm delay is a POINTER-ONLY
// affordance — keyboard ArrowDown always steps immediately, matching
// ArrowUp, so keyboard users are never slower. Every committed step (click,
// key, or held repeat, either direction) fires a short navigator.vibrate()
// pulse where the platform actually supports it — guarded behind a feature
// check, since it's a real no-op on desktop browsers and on macOS
// specifically (no web API reaches trackpad/Force-Touch or keyboard
// haptics). Rack/pawl motion is direct-DOM (imperative ref styles), no
// React state on the animation hot path; prefers-reduced-motion drops the
// tween/kick/snap entirely but keeps the arm timing and every step instant.
// Pure DOM + SVG + CSS, tokens only, no canvas.
// ---------------------------------------------------------------------------

const TOOTH = 20; // px per detent, in the rack SVG's own coordinate units
const VB_W = 240;
const VB_H = 44;
const RAIL_Y = 30;
const TOOTH_H = 10;
const PAWL_X = 62;
const PAWL_TIP_Y = RAIL_Y - TOOTH_H; // pawl tip rests at the tooth peak line

const RACK_MS = 160;
const RACK_EASE = "cubic-bezier(0.16, 1, 0.3, 1)"; // ease-out-expo
const ARM_MS = 250; // pointer-only hold-to-arm threshold, decrement only
const ARM_DEG = 35;
const REPEAT_START_MS = 400; // delay before the first held-repeat step
const REPEAT_MIN_MS = 60; // floor the repeat cadence accelerates toward
const REPEAT_ACCEL = 0.78; // multiplier applied to the delay after each repeat
const SNAP_MS = 260;
const SPRING_EASE = "cubic-bezier(0.34, 1.56, 0.64, 1)"; // one-shot overshoot
const KICK_DEG = 14;
const KICK_UP_MS = 34;
const KICK_DOWN_MS = 46; // up + down ~= 80ms total

// repeating sawtooth path for the rack, generated once — a couple of buffer
// teeth beyond each edge so a one-tooth wrap-snap never reveals a gap
function buildTeethPath() {
  const startI = -2;
  const endI = Math.ceil(VB_W / TOOTH) + 2;
  let d = "";
  for (let i = startI; i <= endI; i++) {
    const x0 = i * TOOTH;
    const peakX = x0 + TOOTH * 0.32;
    const x1 = x0 + TOOTH;
    if (d === "") d += `M ${x0} ${RAIL_Y} `;
    d += `L ${peakX} ${RAIL_Y - TOOTH_H} L ${x1} ${RAIL_Y} `;
  }
  return d;
}
const TEETH_PATH = buildTeethPath();

const btnBase =
  "flex h-11 w-11 shrink-0 select-none items-center justify-center rounded-sm border border-border bg-background text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface";
const btnLive =
  "cursor-pointer hover:border-foreground/30 hover:bg-foreground/[0.06] active:bg-foreground/[0.1] data-[hover=true]:border-foreground/30 data-[hover=true]:bg-foreground/[0.06] data-[press=true]:bg-foreground/[0.1]";
const btnDead = "cursor-default opacity-40";

export interface PawlLiftProps {
  /** controlled value; omit for uncontrolled */
  value?: number;
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  /** accessible name for the spinbutton, and the on-screen caption */
  label?: string;
  /** unit suffix rendered beside the value, e.g. "seats" */
  unit?: string;
  onValueChange?: (value: number) => void;
  disabled?: boolean;
  className?: string;
}

export function PawlLift({
  value,
  defaultValue = 1,
  min = 0,
  max = 99,
  step = 1,
  label = "Quantity",
  unit,
  onValueChange,
  disabled = false,
  className = "",
}: PawlLiftProps) {
  const decimals = (() => {
    const frac = String(step).split(".")[1];
    return frac ? frac.length : 0;
  })();
  const clampRound = (v: number) =>
    Number(Math.min(max, Math.max(min, v)).toFixed(decimals));

  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(() => clampRound(defaultValue));
  const current = isControlled ? clampRound(value) : internal;

  const valueRef = useRef(current);
  valueRef.current = current;
  const boundsRef = useRef({ min, max });
  boundsRef.current = { min, max };
  const stepRef = useRef(step);
  stepRef.current = step;
  const disabledRef = useRef(disabled);
  disabledRef.current = disabled;
  const reducedRef = useRef(false);

  const teethRef = useRef<SVGGElement>(null);
  const pawlRef = useRef<SVGGElement>(null);
  const railOffsetRef = useRef(0);
  const armTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const kickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const [decHover, setDecHover] = useState(false);
  const [decPress, setDecPress] = useState(false);
  const [incHover, setIncHover] = useState(false);
  const [incPress, setIncPress] = useState(false);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onMq = () => {
      reducedRef.current = mq.matches;
    };
    onMq();
    mq.addEventListener("change", onMq);
    return () => mq.removeEventListener("change", onMq);
  }, []);

  useEffect(
    () => () => {
      if (armTimeoutRef.current) clearTimeout(armTimeoutRef.current);
      if (repeatTimeoutRef.current) clearTimeout(repeatTimeoutRef.current);
      if (kickTimeoutRef.current) clearTimeout(kickTimeoutRef.current);
    },
    []
  );

  // ---- rack visual: direct-DOM translateX, wraps modulo one tooth width so
  // the cumulative offset never runs away across many steps -----------------
  const stepRack = (dir: number) => {
    const g = teethRef.current;
    if (!g) return;
    const reduced = reducedRef.current;
    const next = railOffsetRef.current + dir * TOOTH;
    g.style.transition = reduced ? "none" : `transform ${RACK_MS}ms ${RACK_EASE}`;
    g.style.transform = `translateX(${next}px)`;
    railOffsetRef.current = next;
    const wrap = () => {
      const g2 = teethRef.current;
      if (!g2) return;
      if (Math.abs(railOffsetRef.current) >= TOOTH) {
        const wrapped = railOffsetRef.current - Math.sign(railOffsetRef.current) * TOOTH;
        g2.style.transition = "none";
        g2.style.transform = `translateX(${wrapped}px)`;
        railOffsetRef.current = wrapped;
      }
    };
    if (reduced) wrap();
    else setTimeout(wrap, RACK_MS + 30);
  };

  // ---- pawl visual: direct-DOM rotate, one function used for the arm sweep,
  // the release spring, and both halves of the increment kick --------------
  const setPawlAngle = (angle: number, ms: number, ease: string) => {
    const g = pawlRef.current;
    if (!g) return;
    const reduced = reducedRef.current;
    g.style.transition = reduced ? "none" : `transform ${ms}ms ${ease}`;
    g.style.transform = angle ? `rotate(${angle}deg)` : "";
  };

  const playKick = () => {
    if (reducedRef.current) return; // reduced motion: no kick, step is already instant
    if (kickTimeoutRef.current) clearTimeout(kickTimeoutRef.current);
    setPawlAngle(KICK_DEG, KICK_UP_MS, RACK_EASE);
    kickTimeoutRef.current = setTimeout(() => {
      setPawlAngle(0, KICK_DOWN_MS, RACK_EASE);
      kickTimeoutRef.current = null;
    }, KICK_UP_MS);
  };

  // haptic feedback on real hardware; guarded because it's a no-op on every
  // desktop browser and specifically on macOS — there is no web API that
  // reaches trackpad/Force-Touch or keyboard haptics, only mobile vibration
  // motors respond to this at all
  const vibrateStep = () => {
    if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
      navigator.vibrate(10);
    }
  };

  // ---- value commit: shared by every path (click, keys, held repeat) ------
  const commitValue = (next: number) => {
    if (!isControlled) setInternal(next);
    if (next !== valueRef.current) onValueChange?.(next);
    valueRef.current = next;
    vibrateStep();
  };

  // clamped step: returns false (no-op) at a bound so the rack never
  // animates a detent that didn't actually change the value
  const stepCore = (dir: number, withKick: boolean) => {
    if (disabledRef.current) return false;
    const next = clampRound(valueRef.current + stepRef.current * dir);
    if (next === valueRef.current) return false;
    commitValue(next);
    stepRack(dir);
    if (withKick) playKick();
    return true;
  };

  const clearHoldTimers = () => {
    if (armTimeoutRef.current) {
      clearTimeout(armTimeoutRef.current);
      armTimeoutRef.current = null;
    }
    if (repeatTimeoutRef.current) {
      clearTimeout(repeatTimeoutRef.current);
      repeatTimeoutRef.current = null;
    }
  };

  // ---- held-repeat schedule: shared by both directions. Recursive
  // setTimeout (not setInterval) so each tick can shorten the next delay —
  // starts at REPEAT_START_MS and accelerates toward REPEAT_MIN_MS. Stops
  // itself the moment the value reaches the relevant bound. Increment kicks
  // on every repeated step (the free direction always re-seats over a
  // tooth); decrement never kicks during repeat (the pawl stays lifted
  // clear for the whole hold, per the arm/spring above and below it). -----
  const scheduleRepeat = (dir: number, delay: number) => {
    repeatTimeoutRef.current = setTimeout(() => {
      repeatTimeoutRef.current = null;
      const atBound =
        dir < 0
          ? valueRef.current <= boundsRef.current.min
          : valueRef.current >= boundsRef.current.max;
      if (atBound) return;
      stepCore(dir, dir > 0);
      scheduleRepeat(dir, Math.max(REPEAT_MIN_MS, delay * REPEAT_ACCEL));
    }, delay);
  };

  const startIncHold = (e: React.PointerEvent<HTMLButtonElement>) => {
    e.preventDefault();
    if (disabledRef.current) return;
    clearHoldTimers();
    setIncPress(true);
    // free direction: no arm, first step fires right on press
    const stepped = stepCore(1, true);
    if (stepped) scheduleRepeat(1, REPEAT_START_MS);
  };

  const endIncHold = () => {
    clearHoldTimers();
    setIncPress(false);
  };

  const startDecHold = (e: React.PointerEvent<HTMLButtonElement>) => {
    e.preventDefault(); // keep focus where it was, no text selection
    if (disabledRef.current) return;
    clearHoldTimers();
    setDecPress(true);
    setPawlAngle(ARM_DEG, ARM_MS, "linear");
    armTimeoutRef.current = setTimeout(() => {
      armTimeoutRef.current = null;
      const stepped = stepCore(-1, false); // pawl is already lifted clear — no kick
      if (stepped) scheduleRepeat(-1, REPEAT_START_MS);
    }, ARM_MS);
  };

  const endDecHold = () => {
    clearHoldTimers();
    setDecPress(false);
    setPawlAngle(0, SNAP_MS, SPRING_EASE);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (disabled) return;
    if (e.key === "ArrowUp") {
      e.preventDefault();
      stepCore(1, true);
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      // pointer-only friction never applies here — keyboard steps immediately,
      // at whatever cadence the OS's own key-repeat delivers keydown events
      stepCore(-1, true);
    }
  };

  const atMin = current <= min;
  const atMax = current >= max;

  return (
    <div
      className={`rounded-md border border-border bg-surface p-4 ${disabled ? "opacity-60" : ""} ${className}`}
    >
      <div className="mb-3 flex items-center justify-between">
        <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-muted">
          {label}
        </span>
        <span className="font-mono text-[10px] text-muted">
          {min}&ndash;{max}
        </span>
      </div>

      <div
        role="spinbutton"
        tabIndex={disabled ? -1 : 0}
        aria-label={label}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={current}
        aria-valuetext={unit ? `${current} ${unit}` : String(current)}
        aria-disabled={disabled || undefined}
        onKeyDown={onKeyDown}
        className="mb-3 w-full select-none rounded-sm text-center font-mono text-4xl font-semibold tracking-tight text-foreground outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
      >
        {current}
        {unit ? <span className="ml-1.5 text-base font-normal text-muted">{unit}</span> : null}
      </div>

      <div className="relative h-11 w-full overflow-hidden rounded-sm border border-border bg-background">
        <svg
          viewBox={`0 0 ${VB_W} ${VB_H}`}
          preserveAspectRatio="none"
          className="block h-full w-full"
          aria-hidden="true"
          focusable="false"
        >
          <line
            x1={0}
            y1={RAIL_Y + 3}
            x2={VB_W}
            y2={RAIL_Y + 3}
            stroke="var(--border)"
            strokeWidth={1}
          />
          <g ref={teethRef} style={{ willChange: "transform" }}>
            <path
              d={TEETH_PATH}
              fill="none"
              stroke="var(--muted)"
              strokeWidth={1.5}
              strokeLinejoin="round"
              strokeLinecap="round"
            />
          </g>
          <g
            ref={pawlRef}
            style={{ transformBox: "fill-box", transformOrigin: "50% 100%" }}
          >
            <polygon
              points={`${PAWL_X},${PAWL_TIP_Y} ${PAWL_X - 5},${PAWL_TIP_Y - 14} ${PAWL_X + 5},${PAWL_TIP_Y - 14}`}
              fill="none"
              stroke="var(--muted)"
              strokeWidth={2}
              strokeLinejoin="round"
            />
          </g>
        </svg>
      </div>

      <div className="mt-3 flex items-center gap-2">
        <button
          type="button"
          tabIndex={-1}
          aria-label={`Decrease ${label}`}
          aria-disabled={disabled || atMin || undefined}
          onPointerDown={startDecHold}
          onPointerUp={endDecHold}
          onPointerCancel={endDecHold}
          onPointerEnter={() => setDecHover(true)}
          onPointerLeave={() => {
            setDecHover(false);
            endDecHold();
          }}
          onBlur={endDecHold}
          data-hover={decHover}
          data-press={decPress}
          className={`${btnBase} ${disabled || atMin ? btnDead : btnLive}`}
        >
          <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
            <path d="M2.5 7h9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" fill="none" />
          </svg>
        </button>

        <div className="flex-1" />

        <button
          type="button"
          tabIndex={-1}
          aria-label={`Increase ${label}`}
          aria-disabled={disabled || atMax || undefined}
          onPointerEnter={() => setIncHover(true)}
          onPointerLeave={() => {
            setIncHover(false);
            endIncHold();
          }}
          onPointerDown={startIncHold}
          onPointerUp={endIncHold}
          onPointerCancel={endIncHold}
          onBlur={endIncHold}
          data-hover={incHover}
          data-press={incPress}
          className={`${btnBase} ${disabled || atMax ? btnDead : btnLive}`}
        >
          <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
            <path
              d="M7 2.5v9M2.5 7h9"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
              fill="none"
            />
          </svg>
        </button>
      </div>
    </div>
  );
}
Use when

a quantity stepper for a CONSEQUENTIAL value (seats, licenses, allocations) where accidental decrement is costly: incrementing is a free, instant press (and a hold repeats, accelerating from 400ms toward 60ms per step), but decrementing needs a ~250ms pointer hold before the same accelerating repeat starts running backward — direction-dependent friction that makes 'down is deliberate' legible without a confirm dialog. This is a continuing, held control with per-step asymmetric resistance, not a single time-gated commit (see confirm-hold-ink) and not a value-indicating gauge (see stepper-needle, whose stepper is symmetric in both directions).

Build spec

Build a bounded numeric spinbutton whose mechanism is a ratchet and pawl with DIRECTION-DEPENDENT friction. RENDER: a rounded-md border-border bg-surface card with a small-caps mono label row (label left, 'min–max' right), a large font-mono text-4xl value readout that IS the spinbutton (role=spinbutton, tabIndex 0, aria-valuemin/max/now, aria-valuetext with the optional unit), then a 6px-radius (rounded-sm) border-border bg-background strip housing an SVG rack: a horizontal --border baseline, a repeating sawtooth --muted tooth path (a single continuous path built once from an array of tooth positions, not one polygon per tooth), and a small --muted (2px stroke, bolder than the 1.5px teeth so it stays legible as the actor) triangular pawl polygon whose tip rests at the tooth-peak line, positioned with transform-box:fill-box and transform-origin:50% 100% (its tip) so rotation reads as lifting clear rather than orbiting the whole shape — every mechanism stroke is --border/--muted, --foreground is reserved for the value readout above it and --accent for the focus ring, so the number stays the one high-contrast element. Below the strip, two h-11 w-11 rounded-sm bordered buttons (− left, + right), both tabIndex=-1 — the spinbutton owns the keyboard, matching the established 'buttons are pointer-only, input/display owns Arrow keys' idiom already used by this registry's other spinbutton. INCREMENT (free direction): pointerdown on the + button (and ArrowUp on the spinbutton) commits a step immediately, translates the rack's tooth <g> by +TOOTH via direct-DOM style (no React state on the hot path) with a 160ms ease-out-expo (cubic-bezier(0.16,1,0.3,1)) transform transition, and fires an 80ms pawl 'kick': rotate to 14deg over ~34ms then back to 0deg over ~46ms, both via the same imperative rotate helper used everywhere else on the pawl — reads as the pawl riding up and over the new tooth and re-seating. Holding the + button repeats that same step-and-kick for as long as the pointer (mouse or touch) stays down, starting 400ms after the first step and accelerating (×0.78 per repeat) down to a 60ms floor; it stops the instant the value reaches max. DECREMENT (resisted direction, pointer path): pointerdown on the − button starts a 250ms arm timer and immediately begins rotating the pawl to 35deg over exactly those 250ms with a LINEAR transition (no easing — the rotation's pace IS the countdown the user is watching); if released before the timer fires (pointerup/pointercancel/pointerleave/blur), nothing decremented and the pawl springs back to 0deg over 260ms on a one-shot overshoot curve (cubic-bezier(0.34,1.56,0.64,1)), the house 'settle' spring already used elsewhere in this registry; if the hold survives the full 250ms, the timer fires one decrement step (rack translates -TOOTH, same 160ms ease-out-expo, no kick — the pawl is already lifted clear, it has nothing to re-seat into) and then repeats further -1 steps on that same 400ms-accelerating-to-60ms schedule as increment for as long as the pointer stays down, stopping itself the moment the value reaches min; releasing at any point during the hold or repeat clears the schedule and springs the pawl home exactly as an early release does. Every committed step, from either direction or the held repeat, fires a short navigator.vibrate() pulse behind a typeof-navigator.vibrate-is-a-function guard — real feedback on devices with a vibration motor, a harmless no-op everywhere else (notably macOS, where no web API reaches trackpad/Force-Touch or keyboard haptics). Every step, from either direction, is a no-op (no rack animation, no value commit, no onValueChange call, no vibration) if it wouldn't actually change the clamped value — the rack must never visibly move without the number changing under it. KEYBOARD: ArrowUp and ArrowDown on the focused spinbutton both call the SAME immediate-step path as a press on + (value commit, rack nudge, 80ms kick) — the 250ms pointer arm delay never applies to the keyboard, so the first ArrowDown always steps right away and repeats only at whatever cadence the OS's native key-repeat delivers keydown events, meaning keyboard users are never slower on the resisted direction than the free one. Buttons get aria-disabled (not native disabled, so they stay hoverable/focusable-by-script) at min/max and the handlers themselves also guard the bound. INK: every SVG stroke (rail, teeth, pawl) is var(--border)/var(--muted) directly in SVG attributes, never --foreground — no getComputedStyle needed since this is DOM+SVG+CSS with no canvas; --foreground is reserved for the mono value readout and --accent appears nowhere except the spinbutton's focus-visible ring (ring utilities, never outline-none paired with focus-visible:outline — that combination silently zeroes the ring). REDUCED MOTION: every transform-setting call routes through two shared imperative helpers (rack translateX, pawl rotate) that both check prefers-reduced-motion and substitute transition:none for their normal duration — so every value still changes and the 250ms arm timing (a functional gate, not decoration) is still enforced exactly as before, but the rack jumps instead of easing, the pawl jumps between 0/35/kick angles instead of tweening, and the increment kick and release spring both collapse to instant state swaps with nothing skipped functionally. Zero dependencies, pure DOM + SVG + CSS, tokens only, no canvas.

Tags
stepperspinbuttoninputsvgmicro-interactionform