{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kbd-shortcut",
  "type": "registry:ui",
  "title": "Kbd Shortcut",
  "description": "Keycaps for a keyboard shortcut that light up as the keys are physically pressed, with platform-aware modifiers and sequences.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/kbd-shortcut.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport interface KbdShortcutProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  /**\n   * The shortcut. Keys in a chord are joined with \"+\", steps of a sequence with spaces:\n   * \"mod+k\", \"shift+?\", \"g i\" or \"g then i\". `mod` is ⌘ on Apple platforms and Ctrl elsewhere.\n   */\n  shortcut?: string;\n  /**\n   * When keys light up and `onTrigger` fires: while the component has focus, on the whole window\n   * (typing in inputs is ignored), or never (display only).\n   */\n  listen?: \"focus\" | \"window\" | \"none\";\n  /** Called when the full shortcut (every step of a sequence) has been pressed. */\n  onTrigger?: (event: KeyboardEvent) => void;\n  /** Modifier glyphs: auto-detected after hydration (Ctrl on the server), or forced. */\n  platform?: \"auto\" | \"mac\" | \"other\";\n  size?: \"sm\" | \"md\" | \"lg\";\n  variant?: \"raised\" | \"soft\" | \"outline\";\n  /** Show \"+\" between the keys of a chord. */\n  separator?: boolean;\n  /** Time allowed between the steps of a sequence, in ms. */\n  sequenceTimeout?: number;\n  /** Pressed-key tint, completed steps and the trigger flash. */\n  accent?: string;\n}\n\ntype Modifier = \"meta\" | \"ctrl\" | \"alt\" | \"shift\";\ninterface Key {\n  /** Normalised id compared with the pressed key (\"k\", \"meta\", \"up\", \"?\" …). */\n  id: string;\n  modifier?: Modifier;\n}\n\nconst MODIFIER_ALIASES: Record<string, Modifier | \"mod\"> = {\n  mod: \"mod\",\n  cmd: \"meta\",\n  command: \"meta\",\n  meta: \"meta\",\n  super: \"meta\",\n  win: \"meta\",\n  ctrl: \"ctrl\",\n  control: \"ctrl\",\n  alt: \"alt\",\n  option: \"alt\",\n  opt: \"alt\",\n  shift: \"shift\",\n};\nconst KEY_ALIASES: Record<string, string> = {\n  return: \"enter\",\n  escape: \"esc\",\n  del: \"delete\",\n  arrowup: \"up\",\n  arrowdown: \"down\",\n  arrowleft: \"left\",\n  arrowright: \"right\",\n  plus: \"+\",\n  spacebar: \"space\",\n  \" \": \"space\",\n};\nconst MAC_GLYPHS: Record<string, string> = {\n  meta: \"⌘\",\n  ctrl: \"⌃\",\n  alt: \"⌥\",\n  shift: \"⇧\",\n  enter: \"↵\",\n  esc: \"esc\",\n  tab: \"⇥\",\n  backspace: \"⌫\",\n  delete: \"⌦\",\n  space: \"Space\",\n  up: \"↑\",\n  down: \"↓\",\n  left: \"←\",\n  right: \"→\",\n};\nconst OTHER_LABELS: Record<string, string> = {\n  meta: \"Win\",\n  ctrl: \"Ctrl\",\n  alt: \"Alt\",\n  shift: \"Shift\",\n  enter: \"Enter\",\n  esc: \"Esc\",\n  tab: \"Tab\",\n  backspace: \"Backspace\",\n  delete: \"Del\",\n  space: \"Space\",\n  up: \"↑\",\n  down: \"↓\",\n  left: \"←\",\n  right: \"→\",\n  pageup: \"PgUp\",\n  pagedown: \"PgDn\",\n  home: \"Home\",\n  end: \"End\",\n};\nconst SPOKEN: Record<string, string> = {\n  ctrl: \"Control\",\n  shift: \"Shift\",\n  enter: \"Enter\",\n  esc: \"Escape\",\n  tab: \"Tab\",\n  backspace: \"Backspace\",\n  delete: \"Delete\",\n  space: \"Space\",\n  up: \"Up Arrow\",\n  down: \"Down Arrow\",\n  left: \"Left Arrow\",\n  right: \"Right Arrow\",\n  pageup: \"Page Up\",\n  pagedown: \"Page Down\",\n  home: \"Home\",\n  end: \"End\",\n  \"/\": \"Slash\",\n  \"?\": \"Question Mark\",\n  \",\": \"Comma\",\n  \".\": \"Period\",\n  \";\": \"Semicolon\",\n  \"'\": \"Quote\",\n  \"[\": \"Left Bracket\",\n  \"]\": \"Right Bracket\",\n  \"\\\\\": \"Backslash\",\n  \"-\": \"Minus\",\n  \"=\": \"Equals\",\n  \"+\": \"Plus\",\n  \"`\": \"Backtick\",\n};\n\n/** Parses \"mod+shift+p\" / \"g then i\" into steps of keys; `mod` resolves to meta or ctrl. */\nfunction parse(shortcut: string, mac: boolean): Key[][] {\n  return shortcut\n    .trim()\n    .split(/\\s+/)\n    .filter((step) => step && step.toLowerCase() !== \"then\")\n    .map((step) => {\n      // \"ctrl++\" → [\"ctrl\", \"+\"]\n      const tokens = step.endsWith(\"++\") ? [...step.slice(0, -2).split(\"+\"), \"+\"] : step === \"+\" ? [\"+\"] : step.split(\"+\");\n      return tokens\n        .filter(Boolean)\n        .map((raw) => {\n          const token = raw.toLowerCase();\n          const alias = MODIFIER_ALIASES[token];\n          if (alias) {\n            const modifier = alias === \"mod\" ? (mac ? \"meta\" : \"ctrl\") : alias;\n            return { id: modifier, modifier };\n          }\n          return { id: KEY_ALIASES[token] ?? token };\n        })\n        // Modifiers first, in the conventional order for the platform.\n        .sort((a, b) => order(a, mac) - order(b, mac));\n    })\n    .filter((keys) => keys.length > 0);\n}\nfunction order(key: Key, mac: boolean) {\n  if (!key.modifier) return 9;\n  return (mac ? [\"ctrl\", \"alt\", \"shift\", \"meta\"] : [\"ctrl\", \"meta\", \"alt\", \"shift\"]).indexOf(key.modifier);\n}\n\nfunction label(key: Key, mac: boolean) {\n  const table = mac ? MAC_GLYPHS : OTHER_LABELS;\n  if (table[key.id]) return table[key.id];\n  if (/^f\\d{1,2}$/.test(key.id)) return key.id.toUpperCase();\n  return key.id.length === 1 ? key.id.toUpperCase() : key.id.charAt(0).toUpperCase() + key.id.slice(1);\n}\nfunction spoken(key: Key, mac: boolean) {\n  if (key.id === \"meta\") return mac ? \"Command\" : \"Windows\";\n  if (key.id === \"alt\") return mac ? \"Option\" : \"Alt\";\n  if (key.id === \"enter\" && mac) return \"Return\";\n  return SPOKEN[key.id] ?? (key.id.length === 1 ? key.id.toUpperCase() : label(key, mac));\n}\n\n/** Normalised id of the main (non-modifier) key of an event, or the modifier itself. */\nfunction eventKeyId(event: KeyboardEvent): string {\n  if (event.key === \"Meta\" || event.key === \"OS\") return \"meta\";\n  if (event.key === \"Control\") return \"ctrl\";\n  if (event.key === \"Alt\" || event.key === \"AltGraph\") return \"alt\";\n  if (event.key === \"Shift\") return \"shift\";\n  // Physical letters / digits survive Shift and ⌥, which change event.key.\n  if (/^Key[A-Z]$/.test(event.code)) return event.code.slice(3).toLowerCase();\n  if (/^Digit\\d$/.test(event.code)) return event.code.slice(5);\n  const key = event.key.toLowerCase();\n  return KEY_ALIASES[key] ?? key;\n}\n\nfunction chordMatches(keys: Key[], event: KeyboardEvent) {\n  const main = keys.find((k) => !k.modifier);\n  const id = eventKeyId(event);\n  if (!main || main.id !== id) return false;\n  const wants = (m: Modifier) => keys.some((k) => k.modifier === m);\n  if (event.metaKey !== wants(\"meta\") || event.ctrlKey !== wants(\"ctrl\") || event.altKey !== wants(\"alt\")) return false;\n  // Symbols like \"?\" already imply Shift on most layouts, so Shift only has to match when listed or for letters / digits.\n  const symbol = id.length === 1 && !/[a-z0-9]/.test(id);\n  return symbol && !wants(\"shift\") ? true : event.shiftKey === wants(\"shift\");\n}\n\nfunction isEditable(target: EventTarget | null) {\n  if (!(target instanceof HTMLElement)) return false;\n  return target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName);\n}\n\nfunction detectMac() {\n  const nav = navigator as Navigator & { userAgentData?: { platform?: string } };\n  return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? \"\");\n}\nconst noopSubscribe = () => () => {};\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst SIZES = {\n  sm: { cap: \"h-5 min-w-5 rounded-[5px] px-1 text-[11px]\", gap: \"gap-0.5\", plus: \"text-[10px]\", then: \"text-[9px] px-0.5\" },\n  md: { cap: \"h-7 min-w-7 rounded-md px-1.5 text-[13px]\", gap: \"gap-1\", plus: \"text-[11px]\", then: \"text-[10px] px-1\" },\n  lg: { cap: \"h-11 min-w-11 rounded-[9px] px-2.5 text-[17px]\", gap: \"gap-1.5\", plus: \"text-[13px]\", then: \"text-[11px] px-1.5\" },\n} as const;\n\nconst VARIANTS = {\n  raised:\n    \"border border-zinc-900/[0.13] bg-[linear-gradient(180deg,#fff,#f4f4f5)] text-zinc-700 shadow-[inset_0_-2px_0_rgb(24_24_27/0.08),0_1px_1px_rgb(24_24_27/0.06)] dark:border-white/[0.09] dark:bg-[linear-gradient(180deg,#2a2e33,#212529)] dark:text-zinc-200 dark:shadow-[inset_0_-2px_0_rgb(0_0_0/0.35),inset_0_1px_0_rgb(255_255_255/0.05)]\",\n  soft: \"border border-transparent bg-zinc-900/[0.06] text-zinc-700 dark:bg-white/[0.08] dark:text-zinc-200\",\n  outline: \"border border-zinc-900/20 bg-transparent text-zinc-700 dark:border-white/20 dark:text-zinc-200\",\n} as const;\n\n/**\n * Renders a keyboard shortcut as keycaps and lights each key up while it is physically held, so\n * people can try the shortcut right where it is documented. Modifiers follow the platform (⌘ / Ctrl,\n * refined after hydration), sequences such as \"g then i\" show their progress, `onTrigger` fires when\n * the whole shortcut is pressed, and screen readers hear \"Command K\" rather than glyphs.\n */\nexport function KbdShortcut({\n  shortcut = \"mod+k\",\n  listen = \"focus\",\n  onTrigger,\n  platform = \"auto\",\n  size = \"md\",\n  variant = \"raised\",\n  separator = false,\n  sequenceTimeout = 1000,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  onKeyDown,\n  onKeyUp,\n  onBlur,\n  ...props\n}: KbdShortcutProps) {\n  const detected = React.useSyncExternalStore(noopSubscribe, detectMac, () => false);\n  const mac = platform === \"auto\" ? detected : platform === \"mac\";\n  const reduceMotion = usePrefersReducedMotion();\n  const steps = parse(shortcut, mac);\n  const [held, setHeld] = React.useState<string[]>([]);\n  const [step, setStep] = React.useState(0);\n  const [fired, setFired] = React.useState(0);\n  const timer = React.useRef(0);\n  const s = SIZES[size] ?? SIZES.md;\n\n  const spokenText = steps.map((keys) => keys.map((k) => spoken(k, mac)).join(\" \")).join(\", then \");\n\n  const resetSequence = () => {\n    window.clearTimeout(timer.current);\n    setStep(0);\n  };\n\n  /**\n   * Shared key-down logic for focus and window listening. Returns \"trigger\" when the shortcut\n   * completed, \"part\" when the key belongs to it (a modifier or an earlier step) and null otherwise.\n   */\n  const handleDown = (event: KeyboardEvent): \"trigger\" | \"part\" | null => {\n    const id = eventKeyId(event);\n    setHeld((prev) => (prev.includes(id) ? prev : [...prev, id]));\n    if (steps.length === 0) return null;\n    if ([\"meta\", \"ctrl\", \"alt\", \"shift\"].includes(id) || event.repeat) {\n      return steps[step]?.some((k) => k.id === id) ? \"part\" : null;\n    }\n    const at = chordMatches(steps[step] ?? [], event) ? step : chordMatches(steps[0], event) ? 0 : -1;\n    window.clearTimeout(timer.current);\n    if (at === -1) {\n      setStep(0);\n      return null;\n    }\n    if (at === steps.length - 1) {\n      setStep(0);\n      setFired((n) => n + 1);\n      onTrigger?.(event);\n      return \"trigger\";\n    }\n    setStep(at + 1);\n    timer.current = window.setTimeout(() => setStep(0), sequenceTimeout);\n    return \"part\";\n  };\n\n  const handleUp = (event: KeyboardEvent) => {\n    const id = eventKeyId(event);\n    // macOS swallows key-up events of keys pressed while ⌘ is held: releasing ⌘ releases everything.\n    setHeld((prev) => (id === \"meta\" ? prev.filter((k) => [\"ctrl\", \"alt\", \"shift\"].includes(k)) : prev.filter((k) => k !== id)));\n  };\n\n  const release = () => setHeld([]);\n\n  // Window listening calls the latest handlers without re-subscribing on every render.\n  const handlers = React.useRef({ handleDown, handleUp, release });\n  React.useEffect(() => {\n    handlers.current = { handleDown, handleUp, release };\n  });\n  React.useEffect(() => {\n    if (listen !== \"window\") return;\n    const down = (event: KeyboardEvent) => {\n      if (isEditable(event.target)) return;\n      // Only the completing key is consumed; earlier steps (like \"g\") stay ordinary key presses.\n      if (handlers.current.handleDown(event) === \"trigger\") event.preventDefault();\n    };\n    const up = (event: KeyboardEvent) => handlers.current.handleUp(event);\n    const blur = () => handlers.current.release();\n    window.addEventListener(\"keydown\", down);\n    window.addEventListener(\"keyup\", up);\n    window.addEventListener(\"blur\", blur);\n    return () => {\n      window.removeEventListener(\"keydown\", down);\n      window.removeEventListener(\"keyup\", up);\n      window.removeEventListener(\"blur\", blur);\n    };\n  }, [listen]);\n\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  const focusable = listen === \"focus\";\n\n  return (\n    <span\n      role={focusable ? \"group\" : undefined}\n      aria-roledescription={focusable ? \"keyboard shortcut\" : undefined}\n      aria-label={focusable ? spokenText : undefined}\n      tabIndex={focusable ? 0 : undefined}\n      data-listening={listen}\n      className={`relative inline-flex items-center rounded-lg align-middle outline-hidden ${s.gap} ${focusable ? \"cursor-default focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--kbd-accent)]\" : \"\"} ${className}`}\n      style={{ [\"--kbd-accent\" as string]: accent, ...style }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (!focusable || event.defaultPrevented) return;\n        if (event.key === \"Tab\" && !steps.some((keys) => keys.some((k) => k.id === \"tab\"))) return;\n        // Keys that belong to the shortcut are consumed here so page-level shortcuts don't also fire.\n        if (handleDown(event.nativeEvent)) {\n          event.preventDefault();\n          event.stopPropagation();\n        }\n      }}\n      onKeyUp={(event) => {\n        onKeyUp?.(event);\n        if (focusable) handleUp(event.nativeEvent);\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        release();\n        resetSequence();\n      }}\n      {...props}\n    >\n      {focusable ? null : <span className=\"sr-only\">{spokenText}</span>}\n      {steps.map((keys, stepIndex) => (\n        <React.Fragment key={stepIndex}>\n          {stepIndex > 0 ? (\n            <span aria-hidden=\"true\" className={`font-mono uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500 ${s.then}`}>\n              then\n            </span>\n          ) : null}\n          <kbd aria-hidden=\"true\" className={`inline-flex items-center ${s.gap}`}>\n            {keys.map((key, i) => {\n              const done = stepIndex < step;\n              const down = listen !== \"none\" && stepIndex === step && held.includes(key.id);\n              return (\n                <React.Fragment key={`${key.id}-${i}`}>\n                  {i > 0 && separator ? <span className={`text-zinc-400 dark:text-zinc-500 ${s.plus}`}>+</span> : null}\n                  <kbd\n                    data-down={down || undefined}\n                    data-done={done || undefined}\n                    className={`inline-flex select-none items-center justify-center font-mono font-medium leading-none tracking-tight transition-[translate,background-color,border-color,box-shadow,color] duration-75 ${s.cap} ${VARIANTS[variant] ?? VARIANTS.raised} data-[done]:border-[color-mix(in_oklab,var(--kbd-accent)_70%,transparent)] data-[done]:text-zinc-950 dark:data-[done]:text-white data-[down]:translate-y-px data-[down]:border-[color-mix(in_oklab,var(--kbd-accent)_75%,transparent)] data-[down]:bg-[color-mix(in_oklab,var(--kbd-accent)_22%,transparent)] data-[down]:bg-none data-[down]:text-zinc-950 data-[down]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--kbd-accent)_18%,transparent)] motion-reduce:data-[down]:translate-y-0 dark:data-[down]:text-white`}\n                  >\n                    {label(key, mac)}\n                  </kbd>\n                </React.Fragment>\n              );\n            })}\n          </kbd>\n        </React.Fragment>\n      ))}\n      <AnimatePresence>\n        {fired > 0 ? (\n          <motion.span\n            key={fired}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute -inset-1.5 rounded-[10px] border border-[var(--kbd-accent)]\"\n            initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.96 }}\n            animate={{ opacity: [0, 0.9, 0], scale: reduceMotion ? 1 : [0.96, 1, 1.05] }}\n            exit={{ opacity: 0 }}\n            transition={{ duration: reduceMotion ? 0.4 : 0.5, times: [0, 0.2, 1], ease: \"easeOut\" }}\n          />\n        ) : null}\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/kbd-shortcut"
}
