{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"kbd","type":"registry:component","title":"Kbd","description":"A keyboard shortcut written for the reader's keyboard (⌘ on a Mac, Ctrl elsewhere) whose keys sink when you press them for real.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/kbd.tsx","type":"registry:component","content":"// Hairline UI · kbd · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * KBD: a keyboard shortcut, written for the reader's keyboard\n *\n *   platform  \"mod\" reads ⌘ on a Mac and Ctrl elsewhere; alt,\n *             shift and enter get their symbols on a Mac\n *   press     with `live`, each key sinks when you press it for\n *             real, so a shortcut hint answers the keyboard\n * ───────────────────────────────────────────────────────── */\n\nexport interface KbdProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** Keys, e.g. [\"mod\", \"k\"] or \"mod+shift+p\". */\n  keys: string[] | string;\n  /** Keys sink while they're held down. */\n  live?: boolean;\n  size?: \"sm\" | \"md\";\n}\n\nconst MAC: Record<string, string> = { mod: \"⌘\", cmd: \"⌘\", meta: \"⌘\", ctrl: \"⌃\", control: \"⌃\", alt: \"⌥\", option: \"⌥\", shift: \"⇧\", enter: \"↵\", return: \"↵\", backspace: \"⌫\", escape: \"Esc\", esc: \"Esc\", tab: \"⇥\" };\nconst PC: Record<string, string> = { mod: \"Ctrl\", cmd: \"Win\", meta: \"Win\", ctrl: \"Ctrl\", control: \"Ctrl\", alt: \"Alt\", option: \"Alt\", shift: \"Shift\", enter: \"Enter\", return: \"Enter\", backspace: \"Backspace\", escape: \"Esc\", esc: \"Esc\", tab: \"Tab\" };\nconst ARROWS: Record<string, string> = { up: \"↑\", down: \"↓\", left: \"←\", right: \"→\", arrowup: \"↑\", arrowdown: \"↓\", arrowleft: \"←\", arrowright: \"→\" };\n\nconst subscribeNoop = () => () => {};\nconst isMac = () => /Mac|iPhone|iPad/.test(navigator.platform);\n\nfunction matches(key: string, e: KeyboardEvent, mac: boolean) {\n  const k = key.toLowerCase();\n  if (k === \"mod\") return mac ? e.key === \"Meta\" : e.key === \"Control\";\n  if (k === \"cmd\" || k === \"meta\") return e.key === \"Meta\";\n  if (k === \"ctrl\" || k === \"control\") return e.key === \"Control\";\n  if (k === \"alt\" || k === \"option\") return e.key === \"Alt\";\n  if (k === \"shift\") return e.key === \"Shift\";\n  if (k === \"esc\") return e.key === \"Escape\";\n  if (k in ARROWS) return e.key.toLowerCase() === (k.startsWith(\"arrow\") ? k : `arrow${k}`);\n  return e.key.toLowerCase() === k;\n}\n\nexport function Kbd({ keys, live, size = \"md\", className = \"\", ...props }: KbdProps) {\n  const mac = React.useSyncExternalStore(subscribeNoop, isMac, () => true);\n  const list = Array.isArray(keys) ? keys : keys.split(\"+\");\n  const [down, setDown] = React.useState<string[]>([]);\n\n  React.useEffect(() => {\n    if (!live) return;\n    const onDown = (e: KeyboardEvent) => setDown((d) => [...new Set([...d, ...list.filter((k) => matches(k, e, mac))])]);\n    const onUp = (e: KeyboardEvent) => setDown((d) => d.filter((k) => !matches(k, e, mac)));\n    const clear = () => setDown([]);\n    window.addEventListener(\"keydown\", onDown);\n    window.addEventListener(\"keyup\", onUp);\n    window.addEventListener(\"blur\", clear);\n    return () => {\n      window.removeEventListener(\"keydown\", onDown);\n      window.removeEventListener(\"keyup\", onUp);\n      window.removeEventListener(\"blur\", clear);\n    };\n    // list is derived from keys\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [live, mac, list.join(\"+\")]);\n\n  const label = (k: string) => {\n    const l = k.toLowerCase();\n    return ARROWS[l] ?? (mac ? MAC[l] : PC[l]) ?? (k.length === 1 ? k.toUpperCase() : k);\n  };\n\n  // Words for screen readers, not symbols.\n  const spoken = (k: string) => {\n    const l = k.toLowerCase();\n    const words: Record<string, string> = { mod: mac ? \"Command\" : \"Control\", cmd: \"Command\", meta: \"Command\", ctrl: \"Control\", alt: mac ? \"Option\" : \"Alt\", option: \"Option\", shift: \"Shift\" };\n    return words[l] ?? (PC[l] ? PC[l] : l.startsWith(\"arrow\") || l in ARROWS ? `${l.replace(\"arrow\", \"\")} arrow` : label(k));\n  };\n\n  return (\n    <kbd className={`inline-flex items-center gap-1 font-sans ${className}`} aria-label={list.map(spoken).join(\" \")} {...props}>\n      {list.map((k, i) => {\n        const pressed = down.includes(k);\n        return (\n          <span\n            key={k + i}\n            aria-hidden=\"true\"\n            className={`inline-flex items-center justify-center rounded-[6px] bg-background font-medium text-muted-foreground transition-[translate,box-shadow,color] duration-100 ${\n              size === \"sm\" ? \"h-5 min-w-5 px-1 text-[11px]\" : \"h-6 min-w-6 px-1.5 text-[12px]\"\n            } ${\n              pressed\n                ? \"translate-y-px text-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n                : \"shadow-[inset_0_0_0_1px_var(--border),inset_0_-1px_0_var(--border)]\"\n            }`}\n          >\n            {label(k)}\n          </span>\n        );\n      })}\n    </kbd>\n  );\n}\n\nexport default Kbd;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/kbd. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}