{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"push-lock-button","type":"registry:component","title":"Push-lock button","description":"Bottoms out while you hold it, catches halfway when you let go and lights the lamp, and springs back on the next press.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/push-lock-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { cn } from \"@/lib/utils\";\n\n// After Petr Knoll's skeuomorphic CSS button (https://x.com/iampetrknoll):\n// a plastic cap made only of gradients and shadows. Here it's a push-lock\n// switch, the kind on old hi-fi gear: held, it bottoms out; let go, it\n// catches halfway and the lamp comes on; press again and it springs back.\n\ntype Depth = \"up\" | \"latched\" | \"down\";\n\n// The plastic and its panel are materials, not theme colours: pale grey\n// in light mode, graphite in dark.\nconst MATERIAL = {\n  \"--plate\": \"light-dark(#e3e3e5, #222224)\",\n  \"--cap-top\": \"light-dark(#f8f8f9, #4b4b4e)\",\n  \"--cap-bottom\": \"light-dark(#d2d2d5, #2c2c2f)\",\n  \"--ink\": \"light-dark(#232325, #e9e9ea)\",\n  \"--lip\": \"light-dark(rgb(255 255 255 / 0.95), rgb(255 255 255 / 0.14))\",\n  \"--well-top\": \"light-dark(#a9a9ad, #0d0d0e)\",\n  \"--well-bottom\": \"light-dark(#d9d9dc, #303033)\",\n} as React.CSSProperties;\n\n// Every depth lists the same five shadows, so the browser can blend\n// between them instead of snapping: top lip, inner shading, contact,\n// near shadow, far shadow.\nconst CAP: Record<Depth, { transform: string; boxShadow: string }> = {\n  up: {\n    transform: \"translateY(0) scale(1)\",\n    boxShadow:\n      \"inset 0 1px 0 var(--lip), inset 0 -3px 6px rgb(0 0 0 / 0.07), 0 1px 1px rgb(0 0 0 / 0.14), 0 4px 8px -2px rgb(0 0 0 / 0.2), 0 14px 22px -10px rgb(0 0 0 / 0.32)\",\n  },\n  latched: {\n    transform: \"translateY(1px) scale(0.985)\",\n    boxShadow:\n      \"inset 0 1px 0 var(--lip), inset 0 -2px 4px rgb(0 0 0 / 0.06), 0 1px 1px rgb(0 0 0 / 0.18), 0 2px 3px -1px rgb(0 0 0 / 0.16), 0 4px 8px -6px rgb(0 0 0 / 0.2)\",\n  },\n  down: {\n    transform: \"translateY(2px) scale(0.97)\",\n    boxShadow:\n      \"inset 0 0 0 transparent, inset 0 2px 5px rgb(0 0 0 / 0.16), 0 0 0 transparent, 0 0 0 transparent, 0 0 0 transparent\",\n  },\n};\n// How much of the dark recess around the cap shows as it sinks.\nconst WELL: Record<Depth, number> = { up: 0, latched: 0.75, down: 1 };\n// Pressing bottoms out at once; coming back up has a little give.\nconst SINK = \"90ms cubic-bezier(0.23, 1, 0.32, 1)\";\nconst RISE = \"260ms cubic-bezier(0.34, 1.4, 0.64, 1)\";\n\n// The switch's sounds, made on the spot rather than loaded: a burst of\n// noise through a narrow filter is the plastic edge, a short falling sine\n// under it is the body. One context for the page, opened on the first press\n// so the browser allows it.\nlet audio: AudioContext | null = null;\n\ntype Click = {\n  // Where the plastic rings: higher is a sharper, smaller part.\n  pitch: number;\n  ring: number;\n  // The thump of the body under it.\n  body: number;\n  weight: number;\n  length: number;\n};\n\nconst SOUNDS = {\n  // Bottoming out: a full clack with some weight.\n  press: { pitch: 1900, ring: 1.4, body: 150, weight: 0.22, length: 0.03 },\n  // The latch catching: small and bright.\n  latch: { pitch: 3300, ring: 2.2, body: 220, weight: 0.08, length: 0.018 },\n  // Springing back up: lower and hollow.\n  release: { pitch: 1250, ring: 1, body: 115, weight: 0.24, length: 0.045 },\n} satisfies Record<string, Click>;\n\n// Kept low on purpose: it's a detail, and someone may have earbuds in.\nconst VOLUME = 0.35;\n\nfunction playClick({ pitch, ring, body, weight, length }: Click) {\n  try {\n    audio ??= new AudioContext();\n    if (audio.state === \"suspended\") void audio.resume();\n    const a = audio;\n    const t = a.currentTime;\n    const out = a.createGain();\n    out.gain.value = VOLUME;\n    out.connect(a.destination);\n\n    // Noise that dies away fast; a little different every press, the way\n    // no two real clicks match.\n    const frames = Math.ceil(a.sampleRate * length);\n    const buffer = a.createBuffer(1, frames, a.sampleRate);\n    const data = buffer.getChannelData(0);\n    for (let i = 0; i < frames; i++) {\n      data[i] = (Math.random() * 2 - 1) * (1 - i / frames) ** 3;\n    }\n    const noise = a.createBufferSource();\n    noise.buffer = buffer;\n    const band = a.createBiquadFilter();\n    band.type = \"bandpass\";\n    band.frequency.value = pitch * (0.94 + Math.random() * 0.12);\n    band.Q.value = ring;\n    noise.connect(band).connect(out);\n    noise.start(t);\n\n    const thump = a.createOscillator();\n    thump.frequency.setValueAtTime(body, t);\n    thump.frequency.exponentialRampToValueAtTime(body * 0.45, t + 0.05);\n    const fall = a.createGain();\n    fall.gain.setValueAtTime(weight, t);\n    fall.gain.exponentialRampToValueAtTime(0.0001, t + 0.06);\n    thump.connect(fall).connect(out);\n    thump.start(t);\n    thump.stop(t + 0.07);\n  } catch {\n    // No audio (blocked, or an old browser): the switch works silently.\n  }\n}\n\nexport function PushLockButton({\n  label = \"Push\",\n  lamp = \"Power\",\n  defaultOn = false,\n  sound = true,\n  onChange,\n  className,\n}: {\n  label?: string;\n  lamp?: string;\n  defaultOn?: boolean;\n  // Clicks when pressed. Only ever from a real press, never on its own.\n  sound?: boolean;\n  onChange?: (on: boolean) => void;\n  className?: string;\n}) {\n  const play = usePreviewPlay();\n  const [held, setHeld] = useState(false);\n  const [on, setOn] = useState(defaultOn);\n  // The index card works the switch on its own state.\n  const [showHeld, setShowHeld] = useState(false);\n  const [showOn, setShowOn] = useState(false);\n  const isHeld = play ? showHeld : held;\n  const isOn = play ? showOn : on;\n  const depth: Depth = isHeld ? \"down\" : isOn ? \"latched\" : \"up\";\n  const motion = isHeld ? SINK : RISE;\n\n  // The card's show: switched on, left a moment, switched off.\n  useEffect(() => {\n    if (!play) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const press = (at: number, to: boolean) => {\n      timers.push(setTimeout(() => setShowHeld(true), at));\n      timers.push(\n        setTimeout(() => {\n          setShowHeld(false);\n          setShowOn(to);\n        }, at + 220),\n      );\n    };\n    const loop = () => {\n      press(500, true);\n      press(2100, false);\n      timers.push(setTimeout(loop, 3300));\n    };\n    loop();\n    return () => {\n      timers.forEach(clearTimeout);\n      setShowHeld(false);\n      setShowOn(false);\n    };\n  }, [play]);\n\n  const toggle = () => {\n    const next = !on;\n    setOn(next);\n    onChange?.(next);\n    if (sound) playClick(next ? SOUNDS.latch : SOUNDS.release);\n  };\n  const press = () => {\n    setHeld(true);\n    if (sound) playClick(SOUNDS.press);\n  };\n  const isKey = (key: string) => key === \" \" || key === \"Enter\";\n\n  return (\n    <div\n      className={cn(\n        \"relative flex h-[180px] w-[300px] max-w-full items-center justify-center rounded-[30px] bg-(--plate) shadow-[inset_0_1px_0_var(--lip),0_1px_2px_rgb(0_0_0/0.08),0_10px_30px_-12px_rgb(0_0_0/0.25)] select-none\",\n        className,\n      )}\n      style={MATERIAL}\n    >\n      {/* Engraved into the panel, with the lamp beside it. */}\n      <span className=\"absolute top-5 left-6 flex items-center gap-2 font-mono text-[10px] tracking-[0.16em] text-(--ink) uppercase opacity-45\">\n        {lamp}\n      </span>\n      <span\n        aria-hidden\n        className=\"absolute top-[22px] right-6 size-2 rounded-full transition-[background-color,box-shadow] duration-150 ease-out\"\n        style={{\n          // Lights as the latch catches, not the instant it's pressed.\n          transitionDelay: isOn && !isHeld ? \"60ms\" : \"0ms\",\n          backgroundColor: isOn ? \"#ffae2b\" : \"light-dark(#bdbdc0, #3a3a3d)\",\n          boxShadow: isOn\n            ? \"0 0 0 1px rgb(255 140 0 / 0.5), 0 0 10px 2px rgb(255 168 40 / 0.55)\"\n            : \"inset 0 1px 1px rgb(0 0 0 / 0.25)\",\n        }}\n      />\n\n      <span className=\"relative\">\n        {/* The hole in the panel the cap sits in. Hidden under the cap's\n            own shadow at rest; its dark rim shows as the cap goes down. */}\n        <span\n          aria-hidden\n          className=\"absolute -inset-1 rounded-full\"\n          style={{\n            background: \"linear-gradient(180deg, var(--well-top), var(--well-bottom))\",\n            boxShadow: \"inset 0 2px 4px rgb(0 0 0 / 0.45), 0 1px 0 var(--lip)\",\n            opacity: WELL[depth],\n            transition: `opacity ${motion}`,\n          }}\n        />\n        <button\n          type=\"button\"\n          aria-pressed={isOn}\n          onClick={toggle}\n          onPointerDown={(e) => e.button === 0 && press()}\n          onPointerUp={() => setHeld(false)}\n          onPointerLeave={() => setHeld(false)}\n          onPointerCancel={() => setHeld(false)}\n          onKeyDown={(e) => isKey(e.key) && !e.repeat && press()}\n          onKeyUp={(e) => isKey(e.key) && setHeld(false)}\n          onBlur={() => setHeld(false)}\n          className=\"relative flex h-[68px] w-[168px] touch-manipulation items-center justify-center rounded-full text-[20px] font-medium tracking-[-0.01em] text-(--ink) outline-hidden [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-[6px] focus-visible:outline-foreground\"\n          style={{\n            backgroundImage: \"linear-gradient(180deg, var(--cap-top), var(--cap-bottom))\",\n            ...CAP[depth],\n            transition: `transform ${motion}, box-shadow ${motion}`,\n          }}\n        >\n          {/* Pressed in, the face loses its dome and catches less light. */}\n          <span\n            aria-hidden\n            className=\"absolute inset-0 rounded-full bg-black\"\n            style={{\n              opacity: depth === \"down\" ? 0.05 : depth === \"latched\" ? 0.025 : 0,\n              transition: `opacity ${motion}`,\n            }}\n          />\n          <span\n            className=\"relative\"\n            // Moulded into the plastic: a light edge under the letters.\n            style={{ textShadow: \"0 1px 0 var(--lip)\" }}\n          >\n            {label}\n          </span>\n        </button>\n      </span>\n    </div>\n  );\n}\n\nexport default function PushLockButtonDemo() {\n  return <PushLockButton />;\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/push-lock-button"}