Combination Dial
InteractiveA rotary combination-lock control: accessible role="spinbutton" dials rotated by pointer drag or arrow keys, each with aria-valuetext and SVG notch ticks; when every dial matches the combination it fires onUnlock and animates a latch-open reveal. Controlled or uncontrolled, SSR-deterministic. Dials snap without tweening under prefers-reduced-motion.
Inspired by Extracted from the Oubliette template — independently built, not affiliated. Attribution policy
Drag a dial · or focus it and use ↑ ↓
Locked.Installation
npx shadcn@latest add https://parable-three.vercel.app/r/combination-dial.jsonRuns the shadcn CLI — the component source is copied intocomponents/parable/combination-dial.tsx. You own it from there.
Usage
The exact code rendering the preview above.
<div className="w-full px-4 py-2">
<CombinationDial
dialSize={72}
combination={[1, 2, 0]}
hint="Drag a dial · or focus it and use ↑ ↓"
dials={[
{ label: "Dial one of three", faces: [{ label: "Moon", symbol: "🌙" }, { label: "Star", symbol: "⭐" }, { label: "Sun", symbol: "☀️" }] },
{ label: "Dial two of three", faces: [{ label: "Key", symbol: "🔑" }, { label: "Lock", symbol: "🔒" }, { label: "Gem", symbol: "💎" }] },
{ label: "Dial three of three", faces: [{ label: "Owl", symbol: "🦉" }, { label: "Raven", symbol: "🦢" }, { label: "Dove", symbol: "🕊️" }] },
]}
>
<p style={{ textAlign: "center", margin: 0, fontSize: 14 }}>
The latch gives — whisper <strong>CANDLEWICK</strong> at the door.
</p>
</CombinationDial>
</div>Props
CombinationDialProps extends Omit< React.HTMLAttributes<HTMLDivElement>, "onChange" | "children" | "defaultValue" >
| Prop | Type | Default | Description |
|---|---|---|---|
| dials* | DialConfig[] | — | The dials, in left-to-right order. Each dial declares its ordered faces. The number of dials is `dials.length`. |
| combination* | number[] | — | Winning face indices, one per dial (same length/order as `dials`). When every dial's value equals this, the lock unlocks. |
| value | number[] | — | Controlled face indices, one per dial. Provide with `onChange`. |
| defaultValue | number[] | — | Uncontrolled initial face indices. Defaults to all zeros. |
| onChange | (value: number[]) => void | — | Called whenever any dial changes (controlled or uncontrolled). |
| onUnlock | () => void | — | Fired once when the combination is first matched. |
| children | React.ReactNode | ((state: { solved: boolean }) => React.ReactNode) | — | Revealed content once unlocked. A function receives `{ solved }` so callers can render before/after states; a node is shown only after unlock. |
| hint | React.ReactNode | — | Compact hint shown under the dials (e.g. "Drag or use ↑ ↓"). |
| dialSize | number | 88 | Diameter of each dial in px. Default 88. |
| accentColor | string | "var(--primary, #c9a227)" | Accent colour (pointer, solved glow, focus ring). |
| faceColor | string | "var(--foreground, #f5f5f4)" | Face glyph colour. |
| mutedColor | string | "var(--muted-foreground, #8a8578)" | Muted colour for tick marks and labels. |
| trackColor | string | "var(--border, #3a352c)" | Dial track/plate stroke colour. |
| showShackle | boolean | true | Show the decorative padlock shackle above the dials. Default true. |