Escapement
Ratcheting numeric stepper — digits roll with an overshoot-click spring settle, a brass pawl flicks on every step, and hold-to-repeat accelerates like a wound mechanism. Proper spinbutton semantics with arrow keys, Home/End, and live value announcement.
motionfree
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
export interface EscapementProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue"> {
/** Controlled value. */
value?: number;
/** Initial value when uncontrolled. @default 0 (clamped into range) */
defaultValue?: number;
/** Fires with the next value on every step, controlled or not. */
onChange?: (value: number) => void;
/** Lower bound. @default 0 */
min?: number;
/** Upper bound. @default 100 */
max?: number;
/** Amount per step. @default 1 */
step?: number;
/** Accent for the pawl and focus ring. @default "#d4a95c" (brass) */
accentColor?: string;
/** Accessible name for the spinbutton. @default "Value" */
label?: string;
}
function DigitColumn({
char,
direction,
reducedMotion,
}: {
char: string;
direction: number;
reducedMotion: boolean;
}) {
return (
<span className="relative inline-block h-[1.3em] w-[0.62em] overflow-hidden">
<AnimatePresence initial={false} custom={direction}>
<motion.span
key={char}
custom={direction}
variants={{
enter: (d: number) => ({ y: d > 0 ? "105%" : "-105%", opacity: 0.3 }),
center: { y: "0%", opacity: 1 },
exit: (d: number) => ({ y: d > 0 ? "-105%" : "105%", opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 620, damping: 24, mass: 0.6 }
}
className="absolute inset-0 flex items-center justify-center"
>
{char}
</motion.span>
</AnimatePresence>
</span>
);
}
/**
* Escapement — a numeric stepper with a mechanical ratchet feel. Each step
* rolls the digit column with an overshoot-click settle (new digits spring in
* from the direction of travel and catch), while a small brass pawl beneath the
* dial flicks and re-seats like a gear tooth engaging. Hold either button and
* the cadence accelerates like a wound mechanism. Neutral capsule, one brass
* accent.
*
* Accessibility: proper `role="spinbutton"` semantics with Arrow keys,
* PageUp/PageDown, and Home/End on the focusable value; the +/- buttons stay
* out of the tab order per the APG pattern; value changes are announced via a
* polite live region. Honors prefers-reduced-motion: digits swap instantly
* with no springs or pawl flicks.
*/
export function Escapement({
value,
defaultValue,
onChange,
min = 0,
max = 100,
step = 1,
accentColor = "#d4a95c",
label = "Value",
className,
style,
...props
}: EscapementProps) {
const reducedMotion = useReducedMotion();
const clamp = React.useCallback(
(v: number) => Math.min(max, Math.max(min, v)),
[min, max]
);
const isControlled = value !== undefined;
const [internalValue, setInternalValue] = React.useState(() =>
clamp(defaultValue ?? 0)
);
const current = clamp(isControlled ? value : internalValue);
// Direction of the last change — drives the roll direction and pawl flick.
const prevRef = React.useRef(current);
const direction = current >= prevRef.current ? 1 : -1;
React.useEffect(() => {
prevRef.current = current;
}, [current]);
const [hasStepped, setHasStepped] = React.useState(false);
const valueRef = React.useRef(current);
valueRef.current = current;
const commit = React.useCallback(
(next: number) => {
setHasStepped(true);
if (!isControlled) setInternalValue(next);
onChange?.(next);
},
[isControlled, onChange]
);
/** Steps by `d` steps. Returns false when pinned at a bound. */
const stepBy = React.useCallback(
(d: number) => {
const next = clamp(valueRef.current + d * step);
if (next === valueRef.current) return false;
commit(next);
return true;
},
[clamp, step, commit]
);
// Hold-to-repeat with accelerating cadence.
const holdTimerRef = React.useRef(0);
const stopHold = React.useCallback(() => {
window.clearTimeout(holdTimerRef.current);
}, []);
React.useEffect(() => stopHold, [stopHold]);
const startHold = React.useCallback(
(d: number) => {
stopHold();
if (!stepBy(d)) return;
let delay = 340;
const run = () => {
if (!stepBy(d)) return;
delay = Math.max(60, delay * 0.82);
holdTimerRef.current = window.setTimeout(run, delay);
};
holdTimerRef.current = window.setTimeout(run, delay);
},
[stepBy, stopHold]
);
const handleSpinKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
let handled = true;
if (event.key === "ArrowUp" || event.key === "ArrowRight") stepBy(1);
else if (event.key === "ArrowDown" || event.key === "ArrowLeft") stepBy(-1);
else if (event.key === "PageUp") stepBy(10);
else if (event.key === "PageDown") stepBy(-10);
else if (event.key === "Home") {
if (valueRef.current !== min) commit(min);
} else if (event.key === "End") {
if (valueRef.current !== max) commit(max);
} else handled = false;
if (handled) event.preventDefault();
},
[stepBy, commit, min, max]
);
const buttonClass = cn(
"flex size-9 items-center justify-center rounded-full text-neutral-300 transition-colors",
"hover:bg-white/5 hover:text-white active:bg-white/10",
"disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--esc-accent)"
);
const holdButtonProps = (d: number) => ({
tabIndex: -1,
disabled: d > 0 ? current >= max : current <= min,
onPointerDown: (event: React.PointerEvent) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
startHold(d);
},
onPointerUp: stopHold,
onPointerLeave: stopHold,
onPointerCancel: stopHold,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
stepBy(d);
}
},
});
const chars = String(current).split("");
return (
<div
data-crucible="escapement"
className={cn("inline-flex flex-col items-center gap-1.5", className)}
style={{ ["--esc-accent" as string]: accentColor, ...style }}
{...props}
>
<div className="inline-flex items-center gap-0.5 rounded-full border border-white/10 bg-neutral-900 p-1.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_14px_30px_-16px_rgba(0,0,0,0.8)]">
<button
type="button"
aria-label="Decrease"
className={buttonClass}
{...holdButtonProps(-1)}
>
<svg
aria-hidden
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M5 12h14" />
</svg>
</button>
<div
role="spinbutton"
aria-valuenow={current}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={String(current)}
aria-label={label}
tabIndex={0}
onKeyDown={handleSpinKeyDown}
className="relative flex min-w-[3.5ch] items-center justify-center rounded-full px-2 py-1 text-2xl font-semibold tabular-nums text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--esc-accent)"
>
{chars.map((char, i) => (
<DigitColumn
// Keyed by distance from the units place so existing columns
// persist (and roll) when the digit count grows, odometer-style.
key={chars.length - i}
char={char}
direction={direction}
reducedMotion={reducedMotion}
/>
))}
</div>
<button
type="button"
aria-label="Increase"
className={buttonClass}
{...holdButtonProps(1)}
>
<svg
aria-hidden
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
{/* The pawl — flicks and re-seats on every step, like a gear tooth engaging. */}
<motion.svg
key={current}
aria-hidden
viewBox="0 0 12 8"
className="h-2 w-3 text-(--esc-accent)"
initial={
hasStepped && !reducedMotion
? { rotate: direction * 26, y: 1.5 }
: false
}
animate={{ rotate: 0, y: 0 }}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 700, damping: 18, mass: 0.5 }
}
>
<path d="M6 0 11.5 8H0.5L6 0Z" fill="currentColor" />
</motion.svg>
{/* Announces value changes made via the +/- buttons (focus stays off the spinbutton). */}
<span aria-live="polite" className="sr-only">
{current}
</span>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/escapementManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | Controlled value. | |
| defaultValue | number | 0 (clamped into range) | Initial value when uncontrolled. |
| onChange | (value: number) => void | Fires with the next value on every step, controlled or not. | |
| min | number | 0 | Lower bound. |
| max | number | 100 | Upper bound. |
| step | number | 1 | Amount per step. |
| accentColor | string | "#d4a95c" (brass) | Accent for the pawl and focus ring. |
| label | string | "Value" | Accessible name for the spinbutton. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.