{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"kbd","type":"registry:component","title":"Kbd","description":"Keyboard keys as keycaps, for combinations and sequences (G then A); `mod` resolves to ⌘ or Ctrl per platform, symbols are spoken as words.","categories":["display"],"dependencies":["@radix-ui/react-direction@1.1.4","clsx@^2.1.1","tailwind-merge@^3.7.0"],"registryDependencies":["https://beamline.io/registry/look-prism.json"],"files":[{"path":"beamline/components/kbd/defaults.ts","type":"registry:file","target":"src/ui/beamline/components/kbd/defaults.ts","content":"/** Pure allowed presentation defaults for Kbd; no component or runtime imports. */\nexport interface KbdDefaults {\n  /** Shared size setting; omit to inherit the surrounding scope or the component default. */\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n"},{"path":"beamline/components/kbd/kbd.module.css","type":"registry:file","target":"src/ui/beamline/components/kbd/kbd.module.css","content":"@layer properties, theme, base, components, utilities;\n@layer components {\n  /* Keycaps: each key is a small raised control (the control role), so it reads as a key in every look. */\n  .keys {\n    display: inline-flex;\n    align-items: center;\n    gap: var(--pui-space-0-5);\n    vertical-align: middle;\n    /* a key combination is a fixed sequence: it reads Ctrl K in any page direction, isolated as one unit while the\n       page keeps its own direction around it */\n    direction: ltr;\n    unicode-bidi: isolate;\n  }\n  .cap {\n    display: inline-flex;\n    height: 22px;\n    min-width: 22px;\n    align-items: center;\n    justify-content: center;\n    padding-inline: var(--pui-space-1-5);\n    /* nesting input (O1 §14.1, as Button): a parent that holds this near its rounded corner sets --pui-nest-radius and\n       --pui-nest-inset; the corner becomes radius − inset, clamped between a floor (chip) and the part's own role radius (or --pui-nest-ceiling: a pill container sets it to the pill radius). Unset, the declaration is invalid and the own corner\n       applies. --pui-kbd-radius wins over both. */\n    --_kbd-nest: clamp(\n      calc(var(--pui-radius-chip) / 2),\n      calc(var(--pui-nest-radius) - var(--pui-nest-inset, 0px)),\n      var(--pui-nest-ceiling, var(--pui-radius-chip))\n    );\n    border-radius: var(--pui-kbd-radius, var(--_kbd-nest, calc(var(--pui-radius-chip) - 1px)));\n    color: var(--pui-ink-2);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-medium);\n    transition:\n      translate var(--pui-motion-out) var(--pui-ease-default),\n      color var(--pui-motion-out) var(--pui-ease-default),\n      var(--m-transition);\n  }\n  /* live: a held key presses its cap down at once (the look's own press light) and lets it up over the leave\n     duration; with every key of the combination held, the caps ink in the accent and take an accent ring */\n  .cap[data-pressed] {\n    --pui-m-press: 1;\n    translate: 0 1px;\n    color: var(--pui-ink);\n    transition-duration: 0ms;\n  }\n  .keys[data-complete] .cap {\n    color: var(--pui-accent-ink);\n    box-shadow:\n      var(--m-control-edge),\n      0 0 0 1px var(--pui-accent);\n  }\n  /* a finished step of a sequence keeps its caps inked while the next is awaited */\n  .cap[data-done] {\n    color: var(--pui-accent-ink);\n  }\n  /* \"then\" between the steps of a sequence: a word, seen and spoken, so G then A never reads as G and A held together.\n     While the next step is awaited a line under it runs out over the time it has (SEQUENCE_MS in kbd.tsx): a timer,\n     so reduced motion keeps it, as HoldToConfirm keeps its fill. */\n  .then {\n    position: relative;\n    padding-inline: var(--pui-space-0-5);\n    color: var(--pui-ink-3);\n    font-size: var(--pui-text-2xs);\n    font-weight: var(--pui-weight-normal);\n    transition: color var(--pui-motion-out) var(--pui-ease-default);\n  }\n  .then[data-waiting] {\n    color: var(--pui-ink-2);\n    transition-duration: 0ms;\n  }\n  .then[data-waiting]::after {\n    content: \"\";\n    position: absolute;\n    inset-inline: var(--pui-space-0-5);\n    bottom: -2px;\n    height: 1px;\n    background: var(--pui-accent);\n    transform-origin: left;\n    /* the same wait the sequence listener keeps (SEQUENCE_MS), passed in so the line and the timeout never drift */\n    animation: kbd-wait var(--_wait) linear forwards;\n  }\n  @keyframes kbd-wait {\n    from {\n      scale: 1 1;\n    }\n    to {\n      scale: 0 1;\n    }\n  }\n  .sm {\n    height: 18px;\n    min-width: 18px;\n    padding-inline: var(--pui-space-1);\n    font-size: var(--pui-text-2xs);\n  }\n  .lg {\n    height: 28px;\n    min-width: 28px;\n    padding-inline: var(--pui-space-2);\n    font-size: var(--pui-text-sm);\n  }\n}\n/* CSS-module composition only; visual rules stay in the component layer. */\n.cap {\n  composes: m-control from global;\n}\n"},{"path":"beamline/components/kbd/kbd.tsx","type":"registry:file","target":"src/ui/beamline/components/kbd/kbd.tsx","content":"\"use client\";\n\nimport { useMessages, type Translator } from \"../../runtime/localization\";\nimport { english as englishMessages } from \"./messages\";\nimport { useComponentDefaults } from \"../../runtime/appearance\";\nimport type { KbdDefaults } from \"./defaults\";\nexport type { KbdDefaults } from \"./defaults\";\n\nimport { Fragment, useEffect, useReducer } from \"react\";\nimport type { CSSProperties, HTMLAttributes, ReactNode, Ref } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { useApplePlatform } from \"../../hooks/use-apple-platform\";\nimport styles from \"./kbd.module.css\";\n\nconst MAP_MAC = (i18n: Translator<typeof englishMessages>): Record<string, string> => ({\n  mod: \"⌘\",\n  cmd: \"⌘\",\n  ctrl: \"⌃\",\n  alt: \"⌥\",\n  option: \"⌥\",\n  shift: \"⇧\",\n  enter: \"↵\",\n  backspace: \"⌫\",\n  esc: i18n(\"escapeShort\"),\n  tab: \"⇥\",\n  up: \"↑\",\n  down: \"↓\",\n  left: \"←\",\n  right: \"→\",\n  space: i18n(\"space\"),\n});\nconst MAP_PC = (i18n: Translator<typeof englishMessages>): Record<string, string> => ({\n  mod: i18n(\"controlShort\"),\n  cmd: i18n(\"controlShort\"),\n  ctrl: i18n(\"controlShort\"),\n  alt: i18n(\"alt\"),\n  option: i18n(\"alt\"),\n  shift: i18n(\"shift\"),\n  enter: i18n(\"enter\"),\n  backspace: i18n(\"backspace\"),\n  esc: i18n(\"escapeShort\"),\n  tab: i18n(\"tab\"),\n  up: \"↑\",\n  down: \"↓\",\n  left: \"←\",\n  right: \"→\",\n  space: i18n(\"space\"),\n});\nconst SPOKEN = (i18n: Translator<typeof englishMessages>): Record<string, string> => ({\n  \"⌘\": i18n(\"command\"),\n  \"⌃\": i18n(\"control\"),\n  \"⌥\": i18n(\"option\"),\n  \"⇧\": i18n(\"shift\"),\n  \"↵\": i18n(\"enter\"),\n  \"⌫\": i18n(\"backspace\"),\n  \"⇥\": i18n(\"tab\"),\n  \"↑\": i18n(\"up\"),\n  \"↓\": i18n(\"down\"),\n  \"←\": i18n(\"left\"),\n  \"→\": i18n(\"right\"),\n});\n\n/** The KeyboardEvent.key a cap stands for, compared in lower case. */\nconst EVENT_KEY: Record<string, string> = {\n  ctrl: \"control\",\n  alt: \"alt\",\n  option: \"alt\",\n  shift: \"shift\",\n  enter: \"enter\",\n  backspace: \"backspace\",\n  esc: \"escape\",\n  tab: \"tab\",\n  up: \"arrowup\",\n  down: \"arrowdown\",\n  left: \"arrowleft\",\n  right: \"arrowright\",\n  space: \" \",\n};\nconst eventKey = (k: string, mac: boolean) => {\n  const name = k.toLowerCase();\n  if (name === \"mod\" || name === \"cmd\") return mac ? \"meta\" : \"control\";\n  return EVENT_KEY[name] ?? name;\n};\nconst NONE: ReadonlySet<string> = new Set();\n/** The key between the steps of a sequence: [\"g\", \"then\", \"a\"] is G, then A. */\nconst THEN = \"then\";\n/** How long a sequence waits for its next step, as apps that bind them do. */\nconst SEQUENCE_MS = 1500;\n\n/** live: the keys of the shortcut held now, the step being waited for, whether the whole shortcut is done, and how\n *  many steps have been done (a step done again, G pressed twice, gives the next one its full time again). */\ninterface Live {\n  held: ReadonlySet<string>;\n  step: number;\n  complete: boolean;\n  done: number;\n}\ntype LiveAction =\n  { type: \"down\"; key: string; steps: string[][] } | { type: \"up\"; key: string; steps: string[][] } | { type: \"reset\" };\nconst REST: Live = { held: NONE, step: 0, complete: false, done: 0 };\n\n/** A step is done by the press that completes it: every key of the step held, the last one just pressed. */\nconst advance = (live: Live, key: string, steps: string[][]): Live => {\n  const want = steps[live.step];\n  if (!want.includes(key) || !want.every((k) => live.held.has(k))) return live;\n  return live.step === steps.length - 1\n    ? { ...live, complete: true }\n    : { ...live, step: live.step + 1, done: live.done + 1 };\n};\nfunction track(live: Live, action: LiveAction): Live {\n  if (action.type === \"reset\") return live === REST ? live : REST;\n  const { key, steps } = action;\n  if (action.type === \"down\") {\n    // a key outside the shortcut breaks a sequence half-way; a combination simply waits for its own keys\n    if (!steps.some((s) => s.includes(key))) return live.step || live.complete ? { ...REST, held: live.held } : live;\n    const held = live.held.has(key) ? live.held : new Set(live.held).add(key);\n    const next = advance({ ...live, held }, key, steps);\n    if (next.step === live.step && !next.complete && live.step > 0 && !steps[live.step].includes(key))\n      // a wrong key mid-sequence starts it over, and may itself begin it\n      return advance({ ...live, held, step: 0, complete: false }, key, steps);\n    return next;\n  }\n  // with ⌘ held a Mac sends no keyup for the other keys: letting go of ⌘ lets go of all of them\n  let held = live.held;\n  if (key === \"meta\") held = NONE;\n  else if (held.has(key)) {\n    const next = new Set(held);\n    next.delete(key);\n    held = next;\n  }\n  // letting go of the last step ends the shortcut; an earlier step stays done while the next is awaited\n  if (live.complete && steps[live.step].some((k) => !held.has(k))) return { ...REST, held };\n  return held === live.held ? live : { ...live, held };\n}\n\n/** Typing a letter into a field is typing, not a shortcut. */\nconst typing = (e: KeyboardEvent) => {\n  const t = e.target as HTMLElement | null;\n  return (\n    e.key.length === 1 &&\n    !e.metaKey &&\n    !e.ctrlKey &&\n    !e.altKey &&\n    !!t &&\n    (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))\n  );\n};\n\nexport interface KbdClassNames {\n  /** Classes for the root part; no override by default. */\n  root?: string;\n  /** Classes for the cap part; no override by default. */\n  cap?: string;\n  /** Classes for the symbol part; no override by default. */\n  symbol?: string;\n  /** Classes for the spoken part; no override by default. */\n  spoken?: string;\n  /** The word between the steps of a sequence. */\n  then?: string;\n}\n\nexport interface KbdProps extends HTMLAttributes<HTMLElement> {\n  /** Keys in order; held together they make a combination: `[\"mod\", \"K\"]` renders ⌘K on Mac and Ctrl K elsewhere.\n   *  `\"then\"` between keys makes a sequence: `[\"g\", \"then\", \"a\"]` is G, then A. */\n  keys?: string[];\n  /** The caps answer the keyboard: each presses down while the viewer holds its key, and the combination lights when\n   *  all are held; in a sequence each finished step stays lit while the next is awaited (1.5 s). For teaching a\n   *  shortcut where it is shown; it listens only while mounted with `live`. */\n  live?: boolean;\n  /** Platform used to name modifier keys; defaults to the viewer's detected platform. */\n  platform?: \"mac\" | \"other\";\n  /** Control size within the supported size steps. Defaults to \"md\". */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Receives the documented root element; no ref by default. */\n  ref?: Ref<HTMLElement>;\n  /** Classes for the named visual parts; no part overrides by default. */\n  classNames?: KbdClassNames;\n}\n\n/**\n * Keyboard keys as keycaps, for combinations (⌘K) and sequences (G then A). `mod` resolves to ⌘ or Ctrl for the\n * viewer's platform; symbols are spoken as words for screen readers. Pass `children` for a single literal key.\n */\nexport function Kbd({\n  keys,\n  live = false,\n  platform,\n  size: sizeProp,\n  className,\n  classNames,\n  children,\n  ref,\n  ...props\n}: KbdProps) {\n  const i18n = useMessages(\"kbd\", englishMessages);\n\n  const scopeDefaults = useComponentDefaults<KbdDefaults>(\"kbd\", [\"size\"]);\n  const size = sizeProp ?? scopeDefaults.size ?? \"md\";\n\n  const detectedMac = useApplePlatform();\n  const mac = platform === undefined ? detectedMac : platform === \"mac\";\n  const map = mac ? MAP_MAC(i18n) : MAP_PC(i18n);\n  const spoken = SPOKEN(i18n);\n  // the steps: one for a combination, one per \"then\" for a sequence (a stray \"then\" at either end is no step)\n  const parts: string[][] = [[]];\n  for (const k of keys ?? []) {\n    if (k.toLowerCase() === THEN) parts.push([]);\n    else parts[parts.length - 1].push(k);\n  }\n  const steps = parts.filter((s) => s.length > 0);\n  // live: which of the caps' keys the viewer holds now, and how far through the sequence they are\n  const [state, dispatch] = useReducer(track, REST);\n  const wanted = live && keys ? steps.map((s) => s.map((k) => eventKey(k, mac)).join(\"\\u0000\")).join(\"\\u0001\") : \"\";\n  useEffect(() => {\n    if (!wanted) return;\n    const events = wanted.split(\"\\u0001\").map((s) => s.split(\"\\u0000\"));\n    const press = (e: KeyboardEvent) => {\n      if (e.repeat || typing(e)) return;\n      dispatch({ type: \"down\", key: e.key.toLowerCase(), steps: events });\n    };\n    const release = (e: KeyboardEvent) => dispatch({ type: \"up\", key: e.key.toLowerCase(), steps: events });\n    const clear = () => dispatch({ type: \"reset\" });\n    window.addEventListener(\"keydown\", press);\n    window.addEventListener(\"keyup\", release);\n    window.addEventListener(\"blur\", clear);\n    return () => {\n      window.removeEventListener(\"keydown\", press);\n      window.removeEventListener(\"keyup\", release);\n      window.removeEventListener(\"blur\", clear);\n      dispatch({ type: \"reset\" });\n    };\n  }, [wanted]);\n  // a sequence waits for its next step a moment, then starts over\n  const waiting = state.step > 0 && !state.complete;\n  useEffect(() => {\n    if (!waiting) return;\n    const t = window.setTimeout(() => dispatch({ type: \"reset\" }), SEQUENCE_MS);\n    return () => window.clearTimeout(t);\n  }, [waiting, state.done]);\n  const label = (k: string) => map[k.toLowerCase()] ?? (k.length === 1 ? k.toUpperCase() : k);\n  const cap = cn(styles.cap, \"font-sans\", size !== \"md\" && styles[size], classNames?.cap);\n  const face = (k: ReactNode) =>\n    // a label on a plain span is not read; the symbol is hidden and its word is spoken instead\n    typeof k === \"string\" && spoken[k] ? (\n      <>\n        <span data-slot=\"kbd-symbol\" className={classNames?.symbol} aria-hidden>\n          {k}\n        </span>\n        <span data-slot=\"kbd-spoken\" className={cn(\"sr-only\", classNames?.spoken)}>\n          {spoken[k]}\n        </span>\n      </>\n    ) : (\n      k\n    );\n  return (\n    <kbd\n      ref={ref}\n      data-slot=\"kbd\"\n      data-complete={state.complete || undefined}\n      className={cn(styles.keys, classNames?.root, className)}\n      {...props}\n    >\n      {keys ? (\n        steps.map((step, s) => (\n          <Fragment key={s}>\n            {s > 0 && (\n              <span\n                // a new wait restarts the line under it\n                key={waiting && s === state.step ? `then-${state.done}` : \"then\"}\n                data-slot=\"kbd-then\"\n                data-waiting={(waiting && s === state.step) || undefined}\n                className={cn(styles.then, classNames?.then)}\n                style={{ \"--_wait\": `${SEQUENCE_MS}ms` } as CSSProperties}\n              >\n                {i18n(\"then\")}\n              </span>\n            )}\n            {step.map((k, i) => {\n              const event = eventKey(k, mac);\n              return (\n                <span\n                  data-slot=\"kbd-cap\"\n                  key={i}\n                  data-pressed={(live && s === state.step && state.held.has(event)) || undefined}\n                  data-done={(live && s < state.step) || undefined}\n                  className={cap}\n                >\n                  {face(label(k))}\n                </span>\n              );\n            })}\n          </Fragment>\n        ))\n      ) : (\n        <span data-slot=\"kbd-cap\" className={cap}>\n          {face(children)}\n        </span>\n      )}\n    </kbd>\n  );\n}\n\nexport default Kbd;\n"},{"path":"beamline/components/kbd/messages.ts","type":"registry:file","target":"src/ui/beamline/components/kbd/messages.ts","content":"import type { MessageDefinitions } from \"../../runtime/messages\";\n/** Built-in English copy; stable keys are the public translation API. */\nexport const english = {\n  then: \"then\",\n  escapeShort: \"Esc\",\n  space: \"Space\",\n  controlShort: \"Ctrl\",\n  alt: \"Alt\",\n  shift: \"Shift\",\n  enter: \"Enter\",\n  backspace: \"Backspace\",\n  tab: \"Tab\",\n  command: \"Command\",\n  control: \"Control\",\n  option: \"Option\",\n  up: \"Up\",\n  down: \"Down\",\n  left: \"Left\",\n  right: \"Right\",\n} satisfies MessageDefinitions;\n"},{"path":"beamline/hooks/use-apple-platform.ts","type":"registry:file","target":"src/ui/beamline/hooks/use-apple-platform.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { isApplePlatform } from \"../lib/platform\";\n\nconst never = () => () => {};\n\n/** isApplePlatform() for rendering: false on the server and in the first hydrated paint, the real answer after. */\nexport function useApplePlatform() {\n  return useSyncExternalStore(never, isApplePlatform, () => false);\n}\n"},{"path":"beamline/lib/platform.ts","type":"registry:file","target":"src/ui/beamline/lib/platform.ts","content":"/** Whether the person is on an Apple platform, where `mod` is ⌘ (Mac, iPhone, iPad); false where there is no browser. */\nexport const isApplePlatform = () =>\n  typeof navigator !== \"undefined\" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);\n"},{"path":"beamline/lib/utils.ts","type":"registry:file","target":"src/ui/beamline/lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\n/* The type roles (utilities.css) and the 2xs step are sizes, not text colours: without this, tailwind-merge reads\n   `text-overline` as a colour and drops it next to `text-ink-3`. A role and a size step still replace each other.\n   Likewise `font-title` is the look's title weight, not a family: read as a family it dropped `font-display` beside it,\n   and every merged page title lost the look's display face. */\nconst twMerge = extendTailwindMerge({\n  extend: {\n    classGroups: {\n      \"font-weight\": [{ font: [\"title\"] }],\n      \"font-family\": [{ font: [\"display\", \"numeric\", \"figure\"] }],\n      \"font-size\": [\n        {\n          text: [\n            \"2xs\",\n            \"display\",\n            \"title\",\n            \"heading\",\n            \"subheading\",\n            \"body\",\n            \"body-sm\",\n            \"reading\",\n            \"label\",\n            \"caption\",\n            \"overline\",\n            \"numeric\",\n            \"code\",\n          ],\n        },\n      ],\n    },\n  },\n});\n\n/** Merge class names, letting later Tailwind utilities win over earlier ones. */\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/** A small seeded random generator (LCG): the same seed gives the same sequence, for stable sample data and jitter. */\nexport function rng(seed = 1) {\n  let s = seed >>> 0 || 1;\n  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32;\n}\n\nexport const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n"}],"docs":"Kbd is in src/ui/beamline/components/kbd/kbd.tsx. The source is in src/ui/beamline (ui/beamline in a project without src/). After Tailwind, import its styles/beamline.css once, then styles/look-prism.css in your app stylesheet (use relative paths). Keep the shared base before all chosen looks. Wrap your app in <UIRoot preset={prism}> with UIRoot from its runtime/appearance and prism from its looks/prism.  shadcn replaces files when asked; use the Beamline managed-source CLI from the start for three-way upgrades that preserve edits.","meta":{"free":true,"version":"0.2.0-rc.42","library":"https://beamline.io/components/kbd"}}