{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"combination-lock","type":"registry:component","title":"Combination lock","description":"Spin right, left, right to fill each number, then turn the handle to open.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/combination-lock.tsx","type":"registry:component","content":"\"use client\";\n\nimport { memo, useEffect, useId, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useTransform,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst NUMBERS = 40;\nconst STEP = 360 / NUMBERS;\nconst SIZE = 260;\nconst C = SIZE / 2;\n// Right, left, right: each direction change locks in the number before it.\nconst EXPECT = [1, -1, 1] as const;\nconst ORDINAL = [\"first\", \"second\", \"third\"];\n// Pointer this close to the centre gives a meaningless angle, so it's ignored.\nconst DEAD_ZONE = 12;\n// Snaps a released dial onto the nearest number, carrying the hand's speed.\nconst SETTLE = { type: \"spring\", stiffness: 600, damping: 40 } as const;\n// A heavy bolt: it swings open with a touch of overshoot, then stops.\nconst THROW = { type: \"spring\", stiffness: 260, damping: 22 } as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\ntype Lock = { slots: (number | null)[]; index: number; progressed: boolean; live: number | null };\n\nconst emptyLock = (): Lock => ({ slots: [null, null, null], index: 0, progressed: false, live: null });\n\nfunction mod(n: number, m: number) {\n  return ((n % m) + m) % m;\n}\n\n// Rounded to a hundredth: the server and the browser disagree on the last\n// float digits of sin and cos, which is a hydration mismatch otherwise.\nconst round = (v: number) => Math.round(v * 100) / 100;\n\nfunction polar(radius: number, degrees: number) {\n  const a = (degrees * Math.PI) / 180;\n  return [\n    round(C + radius * Math.sin(a)),\n    round(C - radius * Math.cos(a)),\n  ] as const;\n}\n\n// Numbers are printed clockwise, so turning right brings smaller ones to\n// the index, as on a real lock.\nconst numberAt = (step: number) => mod(-step, NUMBERS);\n\n// Holds the dial in each detent: the cubic stays flat near a number and\n// hurries across the gap between two, so a drag clicks from one to the next.\nfunction detented(theta: number) {\n  const s = Math.round(theta / STEP);\n  const f = theta / STEP - s;\n  return (s + 4 * f * f * f) * STEP;\n}\n\nexport function CombinationLock({\n  code,\n  onOpen,\n  className,\n}: {\n  code: [number, number, number];\n  onOpen?: () => void;\n  className?: string;\n}) {\n  const id = useId();\n  const reduceMotion = useReducedMotion();\n  const theta = useMotionValue(0);\n  const shown = useTransform(theta, detented);\n  const handle = useMotionValue(0);\n\n  const [lock, setLock] = useState<Lock>(emptyLock);\n  const [current, setCurrent] = useState(0);\n  const [open, setOpen] = useState(false);\n  const [message, setMessage] = useState(\"\");\n\n  const lockRef = useRef<Lock>(lock);\n  const openRef = useRef(false);\n  const lastStep = useRef(0);\n  const keyTarget = useRef(0);\n  const keyAnim = useRef(false);\n  const dialAnim = useRef<ReturnType<typeof animate> | null>(null);\n  const handleAnim = useRef<ReturnType<typeof animate> | null>(null);\n  const grab = useRef<{ id: number; last: number } | null>(null);\n  const indexRef = useRef<SVGPathElement>(null);\n  const slotsRef = useRef<HTMLDivElement>(null);\n\n  useEffect(\n    () => () => {\n      dialAnim.current?.stop();\n      handleAnim.current?.stop();\n    },\n    [],\n  );\n\n  const publish = (next: Lock) => {\n    lockRef.current = next;\n    setLock(next);\n  };\n\n  const click = () => {\n    if (reduceMotion) return;\n    indexRef.current?.animate(\n      [{ transform: \"translateY(0)\" }, { transform: \"translateY(1.5px)\" }, { transform: \"translateY(0)\" }],\n      { duration: 90, easing: \"cubic-bezier(0.23, 1, 0.32, 1)\" },\n    );\n  };\n\n  const onStep = (dir: number, step: number) => {\n    click();\n    if (openRef.current) return;\n    const L = lockRef.current;\n    if (L.index > 2) return;\n    const number = numberAt(step);\n    if (dir === EXPECT[L.index]) {\n      publish({ ...L, progressed: true, live: number });\n    } else if (L.progressed) {\n      const slots = L.slots.slice();\n      slots[L.index] = numberAt(step - dir);\n      const index = L.index + 1;\n      setMessage(`${ORDINAL[L.index]} number ${slots[L.index]} set`);\n      publish(\n        index > 2\n          ? { slots, index, progressed: false, live: null }\n          : { slots, index, progressed: true, live: number },\n      );\n    }\n  };\n\n  useMotionValueEvent(theta, \"change\", (t) => {\n    const s = Math.round(t / STEP);\n    if (s === lastStep.current) return;\n    while (lastStep.current !== s) {\n      const dir = Math.sign(s - lastStep.current);\n      lastStep.current += dir;\n      onStep(dir, lastStep.current);\n    }\n    setCurrent(numberAt(s));\n  });\n\n  const commitCurrent = () => {\n    const L = lockRef.current;\n    if (L.index > 2) return;\n    const slots = L.slots.slice();\n    slots[L.index] = numberAt(lastStep.current);\n    setMessage(`${ORDINAL[L.index]} number ${slots[L.index]} set`);\n    publish({ slots, index: L.index + 1, progressed: false, live: null });\n  };\n\n  const reset = () => {\n    publish(emptyLock());\n  };\n\n  const turnHandle = (to: number, transition: object) => {\n    handleAnim.current?.stop();\n    if (reduceMotion) {\n      handle.jump(to);\n      return;\n    }\n    handleAnim.current = animate(handle, to, transition);\n  };\n\n  const jiggle = () => {\n    handleAnim.current?.stop();\n    if (reduceMotion) return;\n    // The bolt catches almost at once: a short shove, then it springs back.\n    handleAnim.current = animate(handle, [handle.get(), 12, 0], {\n      duration: 0.32,\n      times: [0, 0.3, 1],\n      ease: [EASE_OUT, [0.34, 1.4, 0.64, 1]],\n    });\n  };\n\n  const tryOpen = () => {\n    if (openRef.current) {\n      openRef.current = false;\n      setOpen(false);\n      setMessage(\"Locked\");\n      turnHandle(0, THROW);\n      reset();\n      return;\n    }\n    let L = lockRef.current;\n    // Turning the handle on the third number counts as entering it.\n    if (L.index === 2 && L.progressed && L.live !== null) {\n      const slots = L.slots.slice();\n      slots[2] = L.live;\n      L = { slots, index: 3, progressed: false, live: null };\n      publish(L);\n    }\n    if (L.index < 3) {\n      jiggle();\n      return;\n    }\n    if (L.slots.every((n, i) => n === code[i])) {\n      openRef.current = true;\n      setOpen(true);\n      setMessage(\"Open\");\n      turnHandle(90, THROW);\n      onOpen?.();\n      return;\n    }\n    jiggle();\n    setMessage(\"Wrong combination, start again\");\n    if (!reduceMotion) {\n      slotsRef.current?.animate(\n        [\n          { transform: \"translateX(0)\" },\n          { transform: \"translateX(-6px)\" },\n          { transform: \"translateX(5px)\" },\n          { transform: \"translateX(-3px)\" },\n          { transform: \"translateX(0)\" },\n        ],\n        { duration: 320, easing: \"ease-out\" },\n      );\n    }\n    reset();\n  };\n\n  const local = (e: React.PointerEvent<HTMLElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    const scale = SIZE / box.width;\n    const x = (e.clientX - box.left) * scale - C;\n    const y = (e.clientY - box.top) * scale - C;\n    return { angle: (Math.atan2(x, -y) * 180) / Math.PI, r: Math.hypot(x, y) };\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0 || grab.current) return;\n    const { angle, r } = local(e);\n    if (r > C) return;\n    dialAnim.current?.stop();\n    keyAnim.current = false;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    grab.current = { id: e.pointerId, last: angle };\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const g = grab.current;\n    if (!g || e.pointerId !== g.id) return;\n    const { angle, r } = local(e);\n    if (r < DEAD_ZONE) return;\n    let delta = angle - g.last;\n    if (delta > 180) delta -= 360;\n    if (delta < -180) delta += 360;\n    g.last = angle;\n    theta.set(theta.get() + delta);\n  };\n\n  const onPointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {\n    const g = grab.current;\n    if (!g || e.pointerId !== g.id) return;\n    grab.current = null;\n    const velocity = theta.getVelocity();\n    const snap = (v: number) => Math.round(v / STEP) * STEP;\n    if (reduceMotion) {\n      theta.jump(snap(theta.get()));\n      return;\n    }\n    // A flick spins on and settles into a number; a slow release just drops\n    // into the nearest detent.\n    dialAnim.current =\n      Math.abs(velocity) > 90\n        ? animate(theta, theta.get(), {\n            type: \"inertia\",\n            velocity,\n            power: 0.3,\n            timeConstant: 200,\n            modifyTarget: snap,\n          })\n        : animate(theta, snap(theta.get()), { ...SETTLE, velocity });\n  };\n\n  const turn = (steps: number) => {\n    const running = keyAnim.current && dialAnim.current?.state === \"running\";\n    // Rapid presses stack onto where the last one was heading.\n    const base = running ? keyTarget.current : Math.round(theta.get() / STEP) * STEP;\n    keyTarget.current = base + steps * STEP;\n    dialAnim.current?.stop();\n    if (reduceMotion) theta.jump(keyTarget.current);\n    else {\n      dialAnim.current = animate(theta, keyTarget.current, SETTLE);\n      keyAnim.current = true;\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const steps = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1, PageDown: 5, PageUp: -5 }[e.key];\n    if (steps !== undefined) {\n      e.preventDefault();\n      turn(steps);\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      if (lockRef.current.index > 2 || openRef.current) tryOpen();\n      else commitCurrent();\n    } else if (e.key === \"Escape\") {\n      reset();\n    }\n  };\n\n  const status = open\n    ? \"Open. Turn the handle to lock.\"\n    : lock.index > 2\n      ? \"Turn the handle\"\n      : `Turn ${EXPECT[lock.index] === 1 ? \"right\" : \"left\"} to the ${ORDINAL[lock.index]} number`;\n\n  return (\n    <div className={cn(\"flex w-[452px] max-w-full flex-col items-center gap-6\", className)}>\n      {/* The safe door. The wrapper leaves 12px on the right for the bolts\n          that stick out of its edge while it is locked. */}\n      <div className=\"relative w-full pr-3\">\n        {/* Steel bolts are a physical material, so they are raw colors in\n            both themes. They sit under the door and slide back into it\n            when the right combination turns the handle. */}\n        {[0.24, 0.5, 0.76].map((at, i) => (\n          <span\n            key={at}\n            aria-hidden\n            className=\"absolute right-0 h-4 w-8 -translate-y-1/2 rounded-r-[4px] shadow-[0_1px_2px_oklch(0_0_0/0.3)] transition-transform duration-300 ease-[cubic-bezier(0.77,0,0.175,1)] motion-reduce:transition-none\"\n            style={{\n              top: `${at * 100}%`,\n              background:\n                \"linear-gradient(to bottom, oklch(0.9 0.005 250), oklch(0.68 0.01 250) 55%, oklch(0.6 0.01 250))\",\n              // 18px pulls the whole visible stub back under the door.\n              transform: open ? \"translate(-18px, -50%)\" : \"translate(0, -50%)\",\n              transitionDelay: `${i * 40}ms`,\n            }}\n          />\n        ))}\n        <div className=\"relative flex flex-col items-center gap-6 rounded-[28px] bg-muted px-5 py-6 shadow-raised sm:px-7\">\n          {/* Rivets in the door's corners. */}\n          {[\"top-3.5 left-3.5\", \"top-3.5 right-3.5\", \"bottom-3.5 left-3.5\", \"bottom-3.5 right-3.5\"].map((at) => (\n            <span\n              key={at}\n              aria-hidden\n              className={cn(\n                \"absolute size-2.5 rounded-full bg-foreground/10 shadow-[inset_0_-1px_1px_oklch(1_0_0/0.35),0_1px_1px_oklch(0_0_0/0.15)] dark:shadow-[inset_0_-1px_1px_oklch(1_0_0/0.08),0_1px_1px_oklch(0_0_0/0.6)]\",\n                at,\n              )}\n            />\n          ))}\n\n          <div ref={slotsRef} className=\"flex gap-3\">\n            {lock.slots.map((n, i) => {\n              const active = !open && lock.index === i;\n              const live = active && lock.progressed ? lock.live : null;\n              return (\n                <div key={i} className=\"flex flex-col items-center gap-1.5\">\n                  <div\n                    className={cn(\n                      \"flex h-14 w-16 items-center justify-center rounded-xl bg-background text-2xl font-medium tabular-nums shadow-wheel transition-[box-shadow] duration-150 ease-out\",\n                      active && \"shadow-[inset_0_0_0_1.5px_var(--foreground)]\",\n                    )}\n                  >\n                    {n !== null ? (\n                      <motion.span\n                        key={`set-${n}`}\n                        initial={{ opacity: 0, filter: \"blur(4px)\", y: reduceMotion ? 0 : 4 }}\n                        animate={{ opacity: 1, filter: \"blur(0px)\", y: 0 }}\n                        transition={{ duration: 0.2, ease: EASE_OUT }}\n                      >\n                        {n}\n                      </motion.span>\n                    ) : live !== null ? (\n                      <span className=\"text-muted-foreground\">{live}</span>\n                    ) : (\n                      <span className=\"h-0.5 w-4 rounded-full bg-border\" />\n                    )}\n                  </div>\n                  <span className={cn(\"flex items-center gap-1 text-xs\", active ? \"text-foreground\" : \"text-muted-foreground\")}>\n                    <svg\n                      viewBox=\"0 0 16 16\"\n                      className={cn(\"size-3\", EXPECT[i] === -1 && \"-scale-x-100\")}\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth={1.5}\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      aria-hidden\n                    >\n                      <path d=\"M13 8a5 5 0 1 1-1.5-3.5M13 2v3h-3\" />\n                    </svg>\n                    {EXPECT[i] === 1 ? \"Right\" : \"Left\"}\n                  </span>\n                </div>\n              );\n            })}\n          </div>\n\n          <div className=\"flex w-full items-center justify-center gap-4 sm:gap-8\">\n            <div\n              role=\"slider\"\n              tabIndex={0}\n              aria-label=\"Combination dial\"\n              aria-valuemin={0}\n              aria-valuemax={NUMBERS - 1}\n              aria-valuenow={current}\n              aria-valuetext={`${current}`}\n              aria-describedby={`${id}-keys`}\n              className=\"relative aspect-square w-[260px] max-w-[calc(100%-92px)] min-w-0 cursor-grab touch-none rounded-full outline-hidden select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-foreground active:cursor-grabbing\"\n              onPointerDown={onPointerDown}\n              onPointerMove={onPointerMove}\n              onPointerUp={onPointerEnd}\n              onPointerCancel={onPointerEnd}\n              onKeyDown={onKeyDown}\n            >\n              {/* The recessed collar the dial turns in. */}\n              <svg viewBox={`0 0 ${SIZE} ${SIZE}`} className=\"absolute inset-0 size-full\" aria-hidden>\n                <circle cx={C} cy={C} r={C - 1} strokeWidth={1} className=\"fill-background stroke-border\" />\n              </svg>\n              <motion.div className=\"absolute inset-0\" style={{ rotate: shown }}>\n                <DialFace id={id} />\n              </motion.div>\n              <svg viewBox={`0 0 ${SIZE} ${SIZE}`} className=\"pointer-events-none absolute inset-0 size-full\" aria-hidden>\n                {/* The index the numbers are read against. */}\n                <path ref={indexRef} d={`M${C - 6} 1L${C + 6} 1L${C} 10Z`} className=\"fill-destructive\" />\n              </svg>\n            </div>\n\n            <button\n              type=\"button\"\n              aria-label={open ? \"Turn handle to lock\" : \"Turn handle to open\"}\n              aria-pressed={open}\n              onClick={tryOpen}\n              className=\"relative flex h-32 w-[76px] shrink-0 items-start justify-center rounded-2xl outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] sm:w-[88px]\"\n            >\n              {/* The spindle plate the handle turns on. */}\n              <span aria-hidden className=\"absolute top-3 left-1/2 size-[72px] -translate-x-1/2 rounded-full bg-background shadow-wheel\" />\n              <motion.span\n                aria-hidden\n                className=\"absolute top-6 left-1/2 -ml-6 size-12\"\n                style={{ rotate: handle, transformOrigin: \"24px 24px\" }}\n              >\n                {/* The lever and its grip. Short enough that, swung open\n                    toward the dial, it clears it. */}\n                <span className=\"absolute top-6 left-[17px] h-[62px] w-3.5 rounded-full bg-foreground shadow-raised\" />\n                <span className=\"absolute top-[74px] left-3.5 size-5 rounded-full bg-foreground shadow-raised\" />\n                {/* The hub, with a turned ring and a cap catching light. */}\n                <span className=\"absolute inset-0 rounded-full bg-foreground shadow-raised\" />\n                <span className=\"absolute inset-[6px] rounded-full border border-background/20\" />\n                <span className=\"absolute inset-[17px] rounded-full bg-background/25\" />\n              </motion.span>\n            </button>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex flex-col items-center gap-1 text-center\">\n        <p className=\"text-sm font-medium\" aria-hidden>\n          {status}\n        </p>\n        <p id={`${id}-keys`} className=\"text-sm text-muted-foreground\">\n          Try 12, 30, 7. Arrows turn, Enter sets a number.\n        </p>\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {message}\n        </span>\n      </div>\n    </div>\n  );\n}\n\nconst DialFace = memo(function DialFace({ id }: { id: string }) {\n  const knurl = Array.from({ length: 96 }, (_, i) => {\n    const [x1, y1] = polar(118, i * 3.75);\n    const [x2, y2] = polar(124, i * 3.75);\n    return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} />;\n  });\n  const ticks = Array.from({ length: NUMBERS }, (_, n) => {\n    const long = n % 5 === 0;\n    const [x1, y1] = polar(long ? 92 : 98, n * STEP);\n    const [x2, y2] = polar(108, n * STEP);\n    return <line key={n} x1={x1} y1={y1} x2={x2} y2={y2} strokeWidth={long ? 1.75 : 1} />;\n  });\n  const labels = Array.from({ length: NUMBERS / 5 }, (_, i) => {\n    const n = i * 5;\n    const [x, y] = polar(78, n * STEP);\n    return (\n      <text\n        key={n}\n        x={x}\n        y={y}\n        dy=\"0.35em\"\n        textAnchor=\"middle\"\n        transform={`rotate(${n * STEP} ${x} ${y})`}\n        className=\"fill-background text-sm font-medium tabular-nums\"\n      >\n        {n}\n      </text>\n    );\n  });\n  return (\n    <svg viewBox={`0 0 ${SIZE} ${SIZE}`} className=\"size-full\" aria-hidden>\n      <defs>\n        {/* Models light falling on turned metal, from the top left. */}\n        <radialGradient id={`${id}-sheen`} cx=\"0.35\" cy=\"0.3\" r=\"0.8\">\n          <stop offset=\"0\" style={{ stopColor: \"var(--background)\", stopOpacity: 0.14 }} />\n          <stop offset=\"1\" style={{ stopColor: \"var(--background)\", stopOpacity: 0 }} />\n        </radialGradient>\n      </defs>\n      <circle cx={C} cy={C} r={124} className=\"fill-foreground\" />\n      <circle cx={C} cy={C} r={124} fill={`url(#${id}-sheen)`} />\n      <g strokeWidth={1.25} className=\"stroke-background/30\">\n        {knurl}\n      </g>\n      <circle cx={C} cy={C} r={114} fill=\"none\" className=\"stroke-background/15\" />\n      <g className=\"stroke-background/70\">{ticks}</g>\n      {labels}\n      {/* The centre knob, with a grip bar that shows the dial turning. */}\n      <circle cx={C} cy={C} r={48} className=\"fill-foreground stroke-background/20\" />\n      <rect x={C - 7} y={C - 40} width={14} height={80} rx={7} className=\"fill-background/10\" />\n    </svg>\n  );\n});\n\nexport default function CombinationLockDemo() {\n  return <CombinationLock code={[12, 30, 7]} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)","shadow-wheel":"inset 0 1px 2px oklch(0 0 0 / 0.08), inset 0 0 0 1px oklch(0 0 0 / 0.04)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)","shadow-wheel":"inset 0 1px 2px oklch(0 0 0 / 0.5), inset 0 0 0 1px oklch(1 0 0 / 0.04)"},"theme":{"shadow-raised":"var(--shadow-raised)","shadow-wheel":"var(--shadow-wheel)"}},"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/combination-lock"}