{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"keycap-button","type":"registry:component","title":"Keycap Button","description":"A button machined like a keycap: a press drops the cap 6px as its skirt collapses, release springs it back past rest, a specular follows the pointer and a copper status light glows while it's held.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/keycap-button.tsx","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\n\n/**\n * KeycapButton — a button machined like a keycap.\n *\n * Its skirt is a stack of hard shadows: a press (pointer, Enter or Space)\n * drops the cap 6px and collapses the skirt in 90ms, release springs it back\n * past rest in 320ms. A specular follows the pointer, the cap lifts 1px on\n * hover, and a copper status light glows while it is held. Three finishes;\n * a ghost variant for the second action. CSS only.\n *\n * Part of the Spatial 3D kit: a dark studio (#0a0a0d, panels #101114 and\n * #16171b, ink #f2f1ee) with anodised copper #ff7a3d only as light, and\n * one object in three finishes (titanium, ceramic, soft-touch polymer) under\n * one rig: key, fill and rim, soft shadows, occlusion and a softbox\n * reflection. Hanken Grotesk through var(--font-hanken) for display over\n * Geist. Respects prefers-reduced-motion. No dependencies beyond React.\n * Paste it as its own file: it repeats the kit's small helpers, which would\n * clash in one module. Load Hanken Grotesk with next/font (variable:\n * \"--font-hanken\") on a parent, or from Google Fonts or @fontsource.\n */\n\nconst SANS = 'var(--font-sans, \"Geist\", \"Inter\", ui-sans-serif, system-ui, sans-serif)';\n\n/**\n * The three finishes the object can wear. `base` is the colour; the rest are\n * shader parameters: gloss (0-1), specular strength, rim, subsurface wrap, and\n * how metallic it is. `css` holds the colours the CSS-3D parts draw with.\n */\nexport const S3_FINISHES = {\n  titanium: {\n    label: \"Titanium\",\n    base: [0.725, 0.737, 0.761],\n    gloss: 0.62,\n    spec: 0.8,\n    rim: 0.3,\n    sss: 0,\n    metal: 1,\n    css: { face: \"#c9ccd2\", side: \"#8d9098\", edge: \"#e6e8ec\", shade: \"#5d6068\" },\n  },\n  ceramic: {\n    label: \"Ceramic\",\n    base: [0.925, 0.906, 0.867],\n    gloss: 0.4,\n    spec: 0.25,\n    rim: 0.18,\n    sss: 0.08,\n    metal: 0,\n    css: { face: \"#ece7dd\", side: \"#c9c2b5\", edge: \"#faf7f1\", shade: \"#9a9386\" },\n  },\n  polymer: {\n    label: \"Polymer\",\n    base: [0.106, 0.11, 0.125],\n    gloss: 0.3,\n    spec: 0.12,\n    rim: 0.22,\n    sss: 0,\n    metal: 0,\n    css: { face: \"#26272c\", side: \"#18191d\", edge: \"#3a3b41\", shade: \"#0e0f12\" },\n  },\n} as const;\n\nexport type S3Finish = keyof typeof S3_FINISHES;\n\nfunction own<T extends object>(map: T, key: string): key is Extract<keyof T, string> {\n  return Object.prototype.hasOwnProperty.call(map, key);\n}\n\nfunction finishOf(name: string) {\n  return own(S3_FINISHES, name) ? S3_FINISHES[name] : S3_FINISHES.titanium;\n}\n\n/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */\nfunction safeHref(raw: string): string {\n  const v = raw.replace(/[\\t\\n\\r]/g, \"\").trim();\n  if (!v || v.includes(\"\\\\\")) return \"#\";\n  if (/^(https?:|mailto:|tel:)/i.test(v)) return v;\n  if (/^[/#?]/.test(v) && !/^\\/\\//.test(v)) return v;\n  return \"#\";\n}\n\n// Each part carries only the CSS it needs, so any one of them works on its own.\n// Nothing is interpolated into these sheets; colours arrive as CSS variables.\n// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.\nconst FOCUS_CSS = \".s3-scope :focus-visible,.s3-scope:focus-visible{outline:2px solid var(--s3-ring,#ff7a3d);outline-offset:3px;border-radius:revert-layer}\";\n\n// The cap's travel and its spring: down in 90ms, back with a slight overshoot in 320ms.\nconst KEYCAP_CSS =\n  \".s3-key{--s3-depth:7px;transform:translateY(0);box-shadow:0 1px 0 var(--s3-side),0 2px 0 var(--s3-side),0 3px 0 var(--s3-side),0 4px 0 var(--s3-side),0 5px 0 var(--s3-side),0 6px 0 var(--s3-side),0 7px 0 var(--s3-shade),0 16px 26px -10px rgba(0,0,0,.65);transition:transform 320ms cubic-bezier(.34,1.56,.64,1),box-shadow 320ms cubic-bezier(.34,1.56,.64,1)}\" +\n  \".s3-key:hover{transform:translateY(-1px);box-shadow:0 1px 0 var(--s3-side),0 2px 0 var(--s3-side),0 3px 0 var(--s3-side),0 4px 0 var(--s3-side),0 5px 0 var(--s3-side),0 6px 0 var(--s3-side),0 7px 0 var(--s3-side),0 8px 0 var(--s3-shade),0 20px 30px -10px rgba(0,0,0,.7)}\" +\n  \".s3-key[data-down]{transform:translateY(6px);box-shadow:0 1px 0 var(--s3-shade),0 6px 12px -8px rgba(0,0,0,.6);transition-duration:90ms;transition-timing-function:ease-out}\" +\n  \".s3-key .s3-spec{background:radial-gradient(120px 70px at var(--s3-mx,30%) var(--s3-my,0%),rgba(255,255,255,.34),transparent 70%);opacity:.8;transition:opacity 220ms}\" +\n  \".s3-key:hover .s3-spec{opacity:1}\" +\n  \".s3-key .s3-led{background:var(--s3-led-off);box-shadow:none;transition:background-color 180ms,box-shadow 180ms}\" +\n  \".s3-key[data-down] .s3-led,.s3-key[data-lit] .s3-led{background:#ff7a3d;box-shadow:0 0 6px 1px rgba(255,122,61,.9),0 0 16px 2px rgba(255,122,61,.45)}\" +\n  \"@media (prefers-reduced-motion: reduce){.s3-key,.s3-key[data-down]{transition:none}}\";\n\nexport type KeycapButtonProps = {\n  children: ReactNode;\n  href?: string;\n  onClick?: () => void;\n  type?: \"button\" | \"submit\";\n  /** \"key\": a raised keycap in a finish; \"ghost\": a hairline key for the second action. */\n  variant?: \"key\" | \"ghost\";\n  finish?: S3Finish;\n  size?: \"lg\" | \"md\";\n  /** Keep the status light on (a chosen option). */\n  lit?: boolean;\n  disabled?: boolean;\n  ariaLabel?: string;\n  ariaPressed?: boolean;\n  className?: string;\n};\n\n/**\n * A button machined like a keycap. Its skirt is a stack of hard shadows, so\n * pressing it (pointer, Enter or Space) drops the cap 6px and the skirt\n * collapses in 90ms; letting go springs it back past rest in 320ms. A soft\n * specular sits where the pointer is, the cap lifts 1px on hover, and a\n * copper status light in the corner glows while it is held (or stays lit\n * with `lit`). Finishes: titanium, ceramic, polymer. Renders a link when\n * given an href, else a button.\n */\nexport function KeycapButton({ children, href, onClick, type = \"button\", variant = \"key\", finish = \"titanium\", size = \"lg\", lit, disabled, ariaLabel, ariaPressed, className }: KeycapButtonProps) {\n  const [down, setDown] = useState(false);\n  const f = finishOf(finish).css;\n  const ghost = variant === \"ghost\";\n  const dark = finish === \"polymer\" || ghost;\n  const style = {\n    \"--s3-side\": ghost ? \"rgba(242,241,238,0.14)\" : f.side,\n    \"--s3-shade\": ghost ? \"rgba(242,241,238,0.06)\" : f.shade,\n    \"--s3-led-off\": dark ? \"rgba(242,241,238,0.18)\" : \"rgba(10,10,13,0.18)\",\n    \"--s3-ring\": \"#ff7a3d\",\n    background: ghost ? \"rgba(22,23,27,0.9)\" : `linear-gradient(180deg, ${f.edge}, ${f.face} 38%, ${f.face} 70%, ${f.side})`,\n    color: dark ? \"rgba(242,241,238,0.92)\" : \"#16171b\",\n    border: ghost ? \"1px solid rgba(242,241,238,0.16)\" : `1px solid ${f.edge}`,\n    touchAction: \"manipulation\",\n  } as CSSProperties;\n  const cls =\n    \"s3-scope s3-key relative inline-flex select-none items-center justify-center gap-3 overflow-hidden rounded-[14px] font-semibold outline-none \" +\n    (size === \"lg\" ? \"h-[52px] px-6 text-[15px] \" : \"h-11 px-5 text-[14px] \") +\n    (disabled ? \"pointer-events-none opacity-45 \" : \"cursor-pointer \") +\n    (className ?? \"\");\n  const press = (on: boolean) => {\n    if (!disabled) setDown(on);\n  };\n  const handlers = {\n    onPointerDown: () => press(true),\n    onPointerUp: () => press(false),\n    onPointerLeave: () => press(false),\n    onPointerCancel: () => press(false),\n    onPointerMove: (e: ReactPointerEvent<HTMLElement>) => {\n      const r = e.currentTarget.getBoundingClientRect();\n      e.currentTarget.style.setProperty(\"--s3-mx\", `${(((e.clientX - r.left) / r.width) * 100).toFixed(1)}%`);\n      e.currentTarget.style.setProperty(\"--s3-my\", `${(((e.clientY - r.top) / r.height) * 100).toFixed(1)}%`);\n    },\n    onKeyDown: (e: ReactKeyboardEvent) => {\n      if (!e.repeat && (e.key === \"Enter\" || e.key === \" \")) press(true);\n    },\n    onKeyUp: () => press(false),\n    onBlur: () => press(false),\n  };\n  const inner = (\n    <>\n      <span aria-hidden className=\"s3-spec pointer-events-none absolute inset-0\" />\n      <span aria-hidden className=\"s3-led absolute left-2.5 top-2.5 size-[5px] rounded-full\" />\n      <span className=\"relative\" style={{ fontFamily: SANS, letterSpacing: \"-0.005em\" }}>\n        {children}\n      </span>\n    </>\n  );\n  const data = { \"data-down\": down ? \"\" : undefined, \"data-lit\": lit ? \"\" : undefined };\n  if (href) {\n    return (\n      <>\n        <style>{FOCUS_CSS + KEYCAP_CSS}</style>\n        <a href={safeHref(href)} onClick={onClick} aria-label={ariaLabel} className={cls} style={style} {...data} {...handlers}>\n          {inner}\n        </a>\n      </>\n    );\n  }\n  return (\n    <>\n      <style>{FOCUS_CSS + KEYCAP_CSS}</style>\n      <button type={type} onClick={onClick} disabled={disabled} aria-label={ariaLabel} aria-pressed={ariaPressed} className={cls} style={style} {...data} {...handlers}>\n        {inner}\n      </button>\n    </>\n  );\n}\n","type":"registry:component","target":"components/viberdy/keycap-button.tsx"}],"meta":{"tier":"free","tags":["button","keycap","3d","press","tactile","css"],"href":"https://viberdy.dev/library/keycap-button","aiPrompt":"Build a React + Tailwind button, `KeycapButton({ children, href, onClick, type, variant, finish, size, lit, disabled, ariaLabel, ariaPressed, className })`.\n\nThe cap: radius 14px, a vertical gradient of the finish's edge, face and side colours, a 1px edge border. The skirt: box-shadow 0 1..6px 0 of the side colour and 0 7px 0 of the shade colour, plus a soft drop shadow. Pressed (pointerdown, or keydown Enter/Space, until up, leave, cancel, blur): translateY(6px) and the skirt reduced to 0 1px 0, in 90ms ease-out; released: back in 320ms cubic-bezier(.34,1.56,.64,1); hover: translateY(-1px) and one more pixel of skirt. A radial specular (120 x 70px) follows CSS variables set from the pointer; a 5px copper light in the top-left corner glows while pressed or when lit. Finishes titanium, ceramic, polymer; a ghost variant is a dark panel with a hairline and a faint skirt. The <style> sits beside the control, not inside it.\n\nSpatial 3D grammar: a dark studio, ground #0a0a0d (panels #101114, raised #16171b, ink #f2f1ee at 92/60/38%, hairlines at 8% and 16%), and one accent, anodised copper #ff7a3d, used only as light (a status arc, a detent marker, an active underglow), never as a fill. One object in three finishes, as material parameters: titanium (base #b9bcc2, gloss 0.62, specular 0.8, fully metallic), ceramic (#ece7dd, gloss 0.4, specular 0.25, a little subsurface wrap) and soft-touch polymer (#1b1c20, gloss 0.3, specular 0.12, the rim kept up so it holds against the ground). One rig for everything: a warm key from (0.55, 0.65, 0.42) at 1.15, a cool fill from (-0.4, -0.15, 0.55) at 0.3, a rim from behind at 0.65, soft shadows (k 16), five-tap occlusion and a studio reflection (a big softbox overhead left, a strip behind right), in linear light with a filmic curve and a 1/255 dither. Parts are blended with small smooth-min radii (8-15% of the smaller part) so the object reads as machined, not melted. Hanken Grotesk through var(--font-hanken, \"Hanken Grotesk\", ...) at 600-800 for display, Geist for reading, Geist Mono uppercase for readouts. Radii 24px panels, 20px cards, 14px controls, the dial full round. Motion: detent springs (stiffness 340, damping 26, ~380ms), orbit inertia that decays in ~270ms, an idle drift of 6 degrees a second on the page clock after 2.5s of stillness, hovers and presses in 90-420ms. Respect prefers-reduced-motion (a still three-quarter view you can still turn by hand; no drift, no scatter). No dependencies beyond React.","license":"https://viberdy.dev/license"}}