{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "keycap-button",
  "type": "registry:ui",
  "title": "Keycap Button",
  "description": "A mechanical keycap with a real skirt and dished top that travels down on a short spring, bottoms out and clacks.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/keycap-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport type KeycapProfile = \"cherry\" | \"flat\";\nexport type KeycapTheme = \"auto\" | \"carbon\" | \"bone\" | \"accent\";\n\nexport interface KeycapButtonProps extends Omit<React.ComponentPropsWithoutRef<\"button\">, \"children\"> {\n  /** The main legend: a character, a word or an inline SVG glyph. */\n  children?: React.ReactNode;\n  /** Smaller secondary legend (e.g. \"shift\" under ⇧, or \"1\" under \"!\"). */\n  subLegend?: React.ReactNode;\n  /**\n   * Accessible name. Defaults to the legend when it is a string; set it for glyphs (\"⌘\" → \"Command\").\n   * When a label is available the printed legends are hidden from assistive tech.\n   */\n  label?: string;\n  /** Where the legend sits on the keycap. */\n  legendPosition?: \"center\" | \"top-left\" | \"bottom-left\";\n  /** Width in keyboard units (1 = square, 2.25 = left shift, 6.25 = spacebar). */\n  units?: number;\n  /** Size of one unit in px (also the key height). */\n  unitSize?: number;\n  /** `cherry`: tall skirt and a dished top. `flat`: low-profile chiclet. */\n  profile?: KeycapProfile;\n  /** Colourway. `auto` is bone on light backgrounds and carbon on dark ones. */\n  theme?: KeycapTheme;\n  /** Accent colour: the `accent` colourway, the latch LED, the clack flash and the focus ring. */\n  accent?: string;\n  /** How far the cap travels before it bottoms out, in px. */\n  travel?: number;\n  /** Flash a brief ring when the key bottoms out. */\n  clack?: boolean;\n  /** Spring used when the key returns up. */\n  spring?: Spring;\n  /** Controlled latch state. Passing it (or `toggle`) turns the key into a toggle with aria-pressed. */\n  pressed?: boolean;\n  /** Initial latch state in uncontrolled toggle mode. */\n  defaultPressed?: boolean;\n  /** Make an uncontrolled key latch (toggle) on each press. */\n  toggle?: boolean;\n  onPressedChange?: (pressed: boolean) => void;\n}\n\n/* --------------------------------- helpers --------------------------------- */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n// Colourways as CSS variables, so the layers below stay theme-agnostic.\nconst BONE =\n  \"[--kc-top:#F6F3EC] [--kc-top2:#ECE7DC] [--kc-skirt:#DDD6C8] [--kc-skirt2:#C4BCAC] [--kc-legend:#26282B] [--kc-sub:#8A8478] [--kc-rim:rgba(255,255,255,0.85)] [--kc-shadow:rgba(60,48,30,0.28)]\";\nconst CARBON =\n  \"[--kc-top:#2D3035] [--kc-top2:#26292D] [--kc-skirt:#1F2225] [--kc-skirt2:#131517] [--kc-legend:#ECEAE4] [--kc-sub:#878C93] [--kc-rim:rgba(255,255,255,0.09)] [--kc-shadow:rgba(0,0,0,0.6)]\";\nconst CARBON_DARK =\n  \"dark:[--kc-top:#2D3035] dark:[--kc-top2:#26292D] dark:[--kc-skirt:#1F2225] dark:[--kc-skirt2:#131517] dark:[--kc-legend:#ECEAE4] dark:[--kc-sub:#878C93] dark:[--kc-rim:rgba(255,255,255,0.09)] dark:[--kc-shadow:rgba(0,0,0,0.6)]\";\nconst ACCENT =\n  \"[--kc-top:var(--kc-accent)] [--kc-top2:color-mix(in_oklab,var(--kc-accent)_90%,black)] [--kc-skirt:color-mix(in_oklab,var(--kc-accent)_74%,black)] [--kc-skirt2:color-mix(in_oklab,var(--kc-accent)_58%,black)] [--kc-legend:#0B0D0F] [--kc-sub:rgba(11,13,15,0.55)] [--kc-rim:rgba(255,255,255,0.4)] [--kc-shadow:color-mix(in_oklab,var(--kc-accent)_30%,rgba(0,0,0,0.5))]\";\n\nconst THEMES: Record<KeycapTheme, string> = {\n  auto: `${BONE} ${CARBON_DARK}`,\n  bone: BONE,\n  carbon: CARBON,\n  accent: ACCENT,\n};\n\nconst PROFILES = {\n  // Inset of the top surface from the skirt edges: [top, sides, bottom] in px.\n  cherry: { inset: [2, 5, 9], radius: 9, topRadius: 7 },\n  flat: { inset: [1.5, 2.5, 4], radius: 7, topRadius: 6 },\n} as const;\n\nconst LEGEND_POSITION = {\n  center: \"items-center justify-center text-center\",\n  \"top-left\": \"items-start justify-start text-left\",\n  \"bottom-left\": \"items-end justify-start text-left\",\n} as const;\n\n/**\n * A mechanical keyboard keycap with real depth: a skirt and an inset top surface that travel down\n * together on a short spring and bottom out, with an optional \"clack\" flash. Works as a normal\n * button (pointer, Enter, Space) or as a latching key with aria-pressed.\n */\nexport const KeycapButton = React.forwardRef<HTMLButtonElement, KeycapButtonProps>(function KeycapButton(\n  {\n    children,\n    subLegend,\n    label,\n    legendPosition = \"center\",\n    units = 1,\n    unitSize = 56,\n    profile = \"cherry\",\n    theme = \"auto\",\n    accent = \"#FF7448\",\n    travel = 4,\n    clack = true,\n    spring = { stiffness: 700, damping: 22, mass: 0.6 },\n    pressed: pressedProp,\n    defaultPressed = false,\n    toggle = false,\n    onPressedChange,\n    className = \"\",\n    style,\n    disabled,\n    onClick,\n    onPointerDown,\n    onPointerUp,\n    onPointerLeave,\n    onPointerCancel,\n    onKeyDown,\n    onKeyUp,\n    onBlur,\n    ...props\n  },\n  ref,\n) {\n  const reduceMotion = usePrefersReducedMotion();\n  const [down, setDown] = React.useState(false);\n  const [clacks, setClacks] = React.useState(0);\n  const [latchedInternal, setLatchedInternal] = React.useState(defaultPressed);\n\n  const isToggle = pressedProp !== undefined || toggle;\n  const latched = isToggle && (pressedProp ?? latchedInternal);\n\n  const shape = PROFILES[profile] ?? PROFILES.cherry;\n  const [insetTop, insetSide, insetBottom] = shape.inset;\n  const accessibleName = label ?? (typeof children === \"string\" ? children : undefined);\n\n  const press = () => {\n    if (disabled || down) return;\n    setDown(true);\n    if (clack && !reduceMotion) setClacks((n) => n + 1);\n  };\n  const release = () => setDown(false);\n\n  // Down: a near-critically-damped spring so the cap bottoms out without overshooting the plate.\n  // Up: the configurable spring, which may bounce a little on the way back.\n  const y = down ? travel : latched ? Math.round(travel * 0.55 * 100) / 100 : 0;\n  const transition = reduceMotion\n    ? { duration: 0 }\n    : down\n      ? { type: \"spring\" as const, stiffness: 1400, damping: 60, mass: 0.4 }\n      : { type: \"spring\" as const, ...spring };\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      disabled={disabled}\n      aria-label={accessibleName}\n      aria-pressed={isToggle ? latched : undefined}\n      data-down={down || undefined}\n      onClick={(event) => {\n        onClick?.(event);\n        if (event.defaultPrevented || !isToggle) return;\n        const next = !latched;\n        if (pressedProp === undefined) setLatchedInternal(next);\n        onPressedChange?.(next);\n      }}\n      onPointerDown={(event) => {\n        onPointerDown?.(event);\n        if (event.button === 0) press();\n      }}\n      onPointerUp={(event) => {\n        onPointerUp?.(event);\n        release();\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        release();\n      }}\n      onPointerCancel={(event) => {\n        onPointerCancel?.(event);\n        release();\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if ((event.key === \"Enter\" || event.key === \" \") && !event.repeat) press();\n      }}\n      onKeyUp={(event) => {\n        onKeyUp?.(event);\n        if (event.key === \"Enter\" || event.key === \" \") release();\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        release();\n      }}\n      className={`group/kc relative inline-block shrink-0 touch-manipulation select-none rounded-[10px] outline-hidden [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-[var(--kc-accent)] disabled:cursor-not-allowed disabled:opacity-45 ${THEMES[theme] ?? THEMES.auto} ${className}`}\n      style={{\n        width: Math.round(units * unitSize),\n        height: unitSize,\n        [\"--kc-accent\" as string]: accent,\n        ...style,\n      }}\n      {...props}\n    >\n      {/* Contact shadow on the plate: tightens as the cap travels down. */}\n      <motion.span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-[3px] -bottom-[3px] top-[8px] bg-[var(--kc-shadow)] blur-[4px]\"\n        style={{ borderRadius: shape.radius }}\n        initial={false}\n        animate={{ opacity: down ? 0.35 : latched ? 0.6 : 1, scaleX: down ? 0.96 : 1 }}\n        transition={reduceMotion ? { duration: 0 } : { duration: 0.12 }}\n      />\n\n      {/* The keycap: skirt + top surface move together. */}\n      <motion.span\n        aria-hidden={accessibleName ? true : undefined}\n        className=\"absolute inset-0 block\"\n        initial={false}\n        animate={{ y }}\n        transition={transition}\n      >\n        {/* Skirt: the sloped sides, darker towards the plate. */}\n        <span\n          className=\"absolute inset-0 block bg-[linear-gradient(180deg,var(--kc-skirt),var(--kc-skirt2))] shadow-[inset_0_1px_0_var(--kc-rim),inset_0_0_0_1px_rgba(0,0,0,0.12)]\"\n          style={{ borderRadius: shape.radius }}\n        />\n        {/* Top surface, inset more at the bottom so the front skirt reads as depth. */}\n        <span\n          className={`absolute flex overflow-hidden bg-[linear-gradient(180deg,var(--kc-top),var(--kc-top2))] px-[0.55em] py-[0.4em] text-[var(--kc-legend)] ${\n            profile === \"cherry\"\n              ? \"shadow-[inset_0_1px_0_var(--kc-rim),inset_0_-3px_6px_rgba(0,0,0,0.07),0_1px_2px_rgba(0,0,0,0.18)]\"\n              : \"shadow-[inset_0_1px_0_var(--kc-rim),0_1px_1px_rgba(0,0,0,0.14)]\"\n          } ${LEGEND_POSITION[legendPosition] ?? LEGEND_POSITION.center}`}\n          style={{\n            top: insetTop,\n            left: insetSide,\n            right: insetSide,\n            bottom: insetBottom,\n            borderRadius: shape.topRadius,\n            fontSize: Math.round(unitSize * 0.27),\n          }}\n        >\n          {/* Cylindrical dish highlight (cherry only). */}\n          {profile === \"cherry\" && (\n            <span className=\"pointer-events-none absolute inset-0 bg-[radial-gradient(120%_85%_at_50%_0%,rgba(255,255,255,0.14),transparent_62%)]\" />\n          )}\n          <span className=\"relative flex flex-col gap-[0.15em] leading-none\">\n            <span className=\"font-medium tracking-tight [&_svg]:h-[1.05em] [&_svg]:w-auto\">{children}</span>\n            {subLegend != null && (\n              <span className=\"font-mono text-[0.5em] uppercase tracking-[0.12em] text-[var(--kc-sub)]\">{subLegend}</span>\n            )}\n          </span>\n\n          {/* Latch LED for toggle keys. */}\n          {isToggle && (\n            <span\n              className={`absolute right-[0.5em] top-[0.5em] size-[5px] rounded-full transition-[background-color,box-shadow] duration-200 ${\n                latched\n                  ? \"bg-[var(--kc-accent)] shadow-[0_0_6px_1px_var(--kc-accent)]\"\n                  : \"bg-[color-mix(in_oklab,var(--kc-legend)_18%,transparent)]\"\n              }`}\n            />\n          )}\n\n          {/* Bottom-out flash on the top surface. */}\n          <AnimatePresence>\n            {clacks > 0 && (\n              <motion.span\n                key={clacks}\n                className=\"pointer-events-none absolute inset-0 bg-white mix-blend-soft-light\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: [0, 0.55, 0] }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.26, times: [0, 0.2, 1], delay: 0.03 }}\n              />\n            )}\n          </AnimatePresence>\n        </span>\n        {/* \"Clack\": a thin accent ring that pops out as the switch bottoms out. */}\n        <AnimatePresence>\n          {clacks > 0 && (\n            <motion.span\n              key={clacks}\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute -inset-[3px] border border-[var(--kc-accent)]\"\n              style={{ borderRadius: shape.radius + 3 }}\n              initial={{ opacity: 0, scale: 0.97 }}\n              animate={{ opacity: [0, 0.8, 0], scale: [0.97, 1.0, 1.06] }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.34, times: [0, 0.18, 1], delay: 0.03, ease: \"easeOut\" }}\n            />\n          )}\n        </AnimatePresence>\n      </motion.span>\n    </button>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/keycap-button"
}
