PParable
All components

Combination Dial

Interactive

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

Runs 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" >

PropTypeDefaultDescription
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.
valuenumber[]Controlled face indices, one per dial. Provide with `onChange`.
defaultValuenumber[]Uncontrolled initial face indices. Defaults to all zeros.
onChange(value: number[]) => voidCalled whenever any dial changes (controlled or uncontrolled).
onUnlock() => voidFired once when the combination is first matched.
childrenReact.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.
hintReact.ReactNodeCompact hint shown under the dials (e.g. "Drag or use ↑ ↓").
dialSizenumber88Diameter of each dial in px. Default 88.
accentColorstring"var(--primary, #c9a227)"Accent colour (pointer, solved glow, focus ring).
faceColorstring"var(--foreground, #f5f5f4)"Face glyph colour.
mutedColorstring"var(--muted-foreground, #8a8578)"Muted colour for tick marks and labels.
trackColorstring"var(--border, #3a352c)"Dial track/plate stroke colour.
showShacklebooleantrueShow the decorative padlock shackle above the dials. Default true.