{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"key-echo","type":"registry:ui","title":"Key echo","description":"Shows the keys you press as caps that pop up, stack and fade, for screen recordings, tutorials and live demos. Pressing the same combination again counts it instead of adding another.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/kbd.tsx","type":"registry:ui","target":"components/ui/kbd.tsx","content":"\"use client\";\n\nimport { useSyncExternalStore, type ComponentProps } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./kbd.css\";\n\nexport type KbdPlatform = \"mac\" | \"other\";\n\n/* Each key: the glyph people see on that platform, and the word a screen reader says. */\nconst named: Record<string, { mac: string; other: string; spoken: string; aria: string }> = {\n  mod: { mac: \"⌘\", other: \"Ctrl\", spoken: \"Command\", aria: \"Meta\" },\n  cmd: { mac: \"⌘\", other: \"⌘\", spoken: \"Command\", aria: \"Meta\" },\n  meta: { mac: \"⌘\", other: \"Win\", spoken: \"Command\", aria: \"Meta\" },\n  ctrl: { mac: \"⌃\", other: \"Ctrl\", spoken: \"Control\", aria: \"Control\" },\n  alt: { mac: \"⌥\", other: \"Alt\", spoken: \"Option\", aria: \"Alt\" },\n  option: { mac: \"⌥\", other: \"Alt\", spoken: \"Option\", aria: \"Alt\" },\n  shift: { mac: \"⇧\", other: \"Shift\", spoken: \"Shift\", aria: \"Shift\" },\n  enter: { mac: \"↵\", other: \"Enter\", spoken: \"Enter\", aria: \"Enter\" },\n  return: { mac: \"↵\", other: \"Enter\", spoken: \"Return\", aria: \"Enter\" },\n  esc: { mac: \"Esc\", other: \"Esc\", spoken: \"Escape\", aria: \"Escape\" },\n  escape: { mac: \"Esc\", other: \"Esc\", spoken: \"Escape\", aria: \"Escape\" },\n  tab: { mac: \"⇥\", other: \"Tab\", spoken: \"Tab\", aria: \"Tab\" },\n  backspace: { mac: \"⌫\", other: \"Backspace\", spoken: \"Backspace\", aria: \"Backspace\" },\n  delete: { mac: \"⌦\", other: \"Del\", spoken: \"Delete\", aria: \"Delete\" },\n  space: { mac: \"Space\", other: \"Space\", spoken: \"Space\", aria: \"Space\" },\n  up: { mac: \"↑\", other: \"↑\", spoken: \"Up arrow\", aria: \"ArrowUp\" },\n  down: { mac: \"↓\", other: \"↓\", spoken: \"Down arrow\", aria: \"ArrowDown\" },\n  left: { mac: \"←\", other: \"←\", spoken: \"Left arrow\", aria: \"ArrowLeft\" },\n  right: { mac: \"→\", other: \"→\", spoken: \"Right arrow\", aria: \"ArrowRight\" },\n  pageup: { mac: \"PgUp\", other: \"PgUp\", spoken: \"Page up\", aria: \"PageUp\" },\n  pagedown: { mac: \"PgDn\", other: \"PgDn\", spoken: \"Page down\", aria: \"PageDown\" },\n  home: { mac: \"Home\", other: \"Home\", spoken: \"Home\", aria: \"Home\" },\n  end: { mac: \"End\", other: \"End\", spoken: \"End\", aria: \"End\" },\n};\n\n/** \"mod+shift+k\" or [\"mod\", \"K\"] → [\"mod\", \"shift\", \"k\"]. A lone \"+\" stays a key. */\nfunction tokens(keys: string | readonly string[]) {\n  if (typeof keys !== \"string\") return [...keys];\n  if (keys === \"+\") return [\"+\"];\n  return keys.split(/\\+(?!$)/u).map((key) => key.trim()).filter(Boolean);\n}\n\nfunction describe(key: string, platform: KbdPlatform) {\n  const known = named[key.toLowerCase()];\n  if (known) return { glyph: known[platform], spoken: known.spoken, aria: known.aria };\n  const glyph = key.length === 1 ? key.toUpperCase() : key;\n  return { glyph, spoken: glyph, aria: glyph };\n}\n\nfunction detect(): KbdPlatform {\n  const nav = navigator as Navigator & { userAgentData?: { platform?: string } };\n  const name = nav.userAgentData?.platform ?? nav.platform ?? \"\";\n  return /mac|iphone|ipad|ipod/iu.test(name) ? \"mac\" : \"other\";\n}\n\n/** The platform's modifier names. The server renders \"other\"; Macs switch to ⌘ after hydration. */\nexport function useKbdPlatform(platform?: KbdPlatform): KbdPlatform {\n  const detected = useSyncExternalStore(\n    () => () => {},\n    detect,\n    () => \"other\" as const,\n  );\n  return platform ?? detected;\n}\n\n/** The value for `aria-keyshortcuts` on the control the keys trigger, e.g. \"Meta+K\". */\nexport function toAriaKeyshortcuts(keys: string | readonly string[], platform: KbdPlatform = \"other\") {\n  return tokens(keys)\n    .map((key) => {\n      if (key.toLowerCase() === \"mod\") return platform === \"mac\" ? \"Meta\" : \"Control\";\n      return describe(key, platform).aria;\n    })\n    .join(\"+\");\n}\n\nexport type KbdProps = Omit<ComponentProps<\"kbd\">, \"children\"> & {\n  /** One key or a combination: \"mod+k\", \"shift+enter\", [\"alt\", \"↑\"]. `mod` is ⌘ on a Mac and Ctrl elsewhere. */\n  keys?: string | readonly string[];\n  /** Free text instead of keys, e.g. \"G then D\". */\n  children?: string;\n  /** `inline` sits quietly inside a button, menu row or tooltip and takes its colour. */\n  variant?: \"default\" | \"inline\";\n  size?: \"sm\" | \"md\";\n  /** Force a platform instead of detecting it, e.g. in documentation. */\n  platform?: KbdPlatform;\n};\n\n/**\n * Keys drawn as small raised caps. A combination is one `kbd` holding a cap\n * per key, so it reads as a single shortcut; symbols such as ⌘ carry their\n * spoken name for screen readers.\n */\nexport function Kbd({ keys, children, variant = \"default\", size = \"md\", platform, className, ...props }: KbdProps) {\n  const resolved = useKbdPlatform(platform);\n  const list = keys !== undefined ? tokens(keys) : children !== undefined ? [children] : [];\n  const parts = list.map((key) => describe(key, resolved));\n  const shared = { \"data-variant\": variant, \"data-size\": size } as const;\n  if (parts.length === 1) {\n    return (\n      <kbd data-slot=\"kbd\" {...shared} className={className} {...props}>\n        <Glyph {...parts[0]!} />\n      </kbd>\n    );\n  }\n  return (\n    <kbd data-slot=\"kbd-group\" {...shared} className={cn(className)} {...props}>\n      {parts.map((part, index) => (\n        <kbd key={index} data-slot=\"kbd\" {...shared}>\n          {index > 0 && <span className=\"sr-only\"> </span>}\n          <Glyph {...part} />\n        </kbd>\n      ))}\n    </kbd>\n  );\n}\n\nfunction Glyph({ glyph, spoken }: { glyph: string; spoken: string }) {\n  if (glyph === spoken) return glyph;\n  return (\n    <>\n      <span aria-hidden=\"true\">{glyph}</span>\n      <span className=\"sr-only\">{spoken}</span>\n    </>\n  );\n}\n"},{"path":"ui/key-echo.tsx","type":"registry:ui","target":"components/ui/key-echo.tsx","content":"\"use client\";\nimport { useEffect, useRef, useState, type ComponentProps } from \"react\";\nimport { Kbd } from \"./kbd\";\nimport { cn } from \"./lib/cn\";\nimport \"./key-echo.css\";\n\nexport type KeyEchoProps = Omit<ComponentProps<\"div\">, \"children\"> & {\n  /** Echo keys pressed anywhere on the page, or only inside the parent element. */\n  scope?: \"page\" | \"parent\";\n  /** How many combinations stay on screen at once. */\n  max?: number;\n  /** How long a combination stays, in ms. */\n  linger?: number;\n};\n\ntype Echo = { id: number; keys: string[]; times: number };\nconst names: Record<string, string> = { \" \": \"Space\", ArrowUp: \"↑\", ArrowDown: \"↓\", ArrowLeft: \"←\", ArrowRight: \"→\", Escape: \"Esc\", Backspace: \"⌫\", Enter: \"↵\" };\nconst modifiers = [\"Control\", \"Meta\", \"Alt\", \"Shift\"];\n\n/** The caps for one key event: modifiers first, then the key, for example [\"mod\", \"shift\", \"K\"]. */\nexport function comboOf(event: Pick<KeyboardEvent, \"key\" | \"metaKey\" | \"ctrlKey\" | \"altKey\" | \"shiftKey\">) {\n  if (modifiers.includes(event.key)) return null;\n  const keys: string[] = [];\n  if (event.metaKey) keys.push(\"meta\");\n  if (event.ctrlKey) keys.push(\"ctrl\");\n  if (event.altKey) keys.push(\"alt\");\n  // Shift is shown only with another modifier or a non-character key, as \"A\" already says shift.\n  if (event.shiftKey && (keys.length || event.key.length > 1)) keys.push(\"shift\");\n  keys.push(names[event.key] ?? (event.key.length === 1 ? event.key.toUpperCase() : event.key));\n  return keys;\n}\n\n/**\n * Shows the keys you press as caps that pop up, stack and fade, for screen recordings, tutorials and live demos.\n * Pressing the same combination again counts it instead of adding another. It is a visual echo only, hidden from\n * assistive technology, and ignores keys typed into password fields. Under reduced motion caps appear and leave\n * without travel.\n */\nexport function KeyEcho({ scope = \"page\", max = 3, linger = 1600, className, ...props }: KeyEchoProps) {\n  const [echoes, setEchoes] = useState<Echo[]>([]);\n  const root = useRef<HTMLDivElement>(null);\n  const next = useRef(0);\n\n  useEffect(() => {\n    const target = scope === \"page\" ? document : root.current?.parentElement;\n    if (!target) return;\n    const down = (event: Event) => {\n      const key = event as KeyboardEvent;\n      if ((key.target as HTMLInputElement | null)?.type === \"password\") return;\n      const keys = comboOf(key);\n      if (!keys) return;\n      setEchoes((current) => {\n        const last = current[current.length - 1];\n        if (last && last.keys.join(\"+\") === keys.join(\"+\")) return [...current.slice(0, -1), { ...last, id: next.current++, times: last.times + 1 }];\n        return [...current, { id: next.current++, keys, times: 1 }].slice(-max);\n      });\n    };\n    target.addEventListener(\"keydown\", down);\n    return () => target.removeEventListener(\"keydown\", down);\n  }, [scope, max]);\n\n  // Each combination leaves on its own clock; a repeat restarts it.\n  useEffect(() => {\n    if (!echoes.length) return;\n    const oldest = echoes[0];\n    const timer = window.setTimeout(() => setEchoes((current) => current.filter((echo) => echo.id !== oldest.id)), linger);\n    return () => window.clearTimeout(timer);\n  }, [echoes, linger]);\n\n  return (\n    <div ref={root} data-slot=\"key-echo\" aria-hidden=\"true\" className={cn(\"pointer-events-none flex flex-col items-center gap-2\", className)} {...props}>\n      {echoes.map((echo) => (\n        <span key={echo.id} data-slot=\"ke-combo\" data-repeat={echo.times > 1 ? \"\" : undefined}>\n          <Kbd keys={echo.keys} size=\"md\" />\n          {echo.times > 1 && <span data-slot=\"ke-times\">×{echo.times}</span>}\n        </span>\n      ))}\n    </div>\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/kbd.css","type":"registry:file","target":"components/ui/kbd.css","content":"/* kbd.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Key caps: a small raised object with a lip on the bottom edge. */\n  [data-slot=\"kbd-group\"] { display: inline-flex; align-items: center; gap: 3px; font: inherit; }\n  [data-slot=\"kbd\"] {\n    display: inline-flex; align-items: center; justify-content: center;\n    min-width: 20px; height: 20px; padding: 0 5px;\n    border-radius: var(--radius-mark);\n    background: var(--raised);\n    box-shadow: inset 0 0 0 1px var(--border), inset 0 -1px 0 var(--border-strong);\n    color: var(--muted-foreground);\n    font-family: var(--font-mono, ui-monospace, monospace); font-size: 12px; line-height: 1; font-weight: 500;\n    font-variant-numeric: tabular-nums; white-space: nowrap;\n  }\n  /* A key hint inside a search box or field is a soft fill: the box already has the border. */\n  :is([data-slot=\"input-group\"], [data-slot=\"search\"], [data-slot$=\"-search\"], [data-slot=\"command-input\"]) [data-slot=\"kbd\"] { background: var(--muted); box-shadow: none; border: 0; }\n  [data-slot=\"kbd\"][data-size=\"sm\"] { min-width: 18px; height: 18px; padding: 0 4px; font-size: 12px; }\n  [data-slot=\"kbd\"][data-variant=\"inline\"] {\n    background: transparent; color: inherit; opacity: 0.72;\n    box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 26%, transparent);\n  }\n  [data-slot=\"kbd-group\"][data-variant=\"inline\"] { opacity: 1; }\n}\n"},{"path":"ui/key-echo.css","type":"registry:file","target":"components/ui/key-echo.css","content":"/* key-echo.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Key echo: caps rise in, a repeat bumps its count, and old combinations fade up and out. */\n  [data-slot=\"key-echo\"] [data-slot=\"ke-combo\"] { display: inline-flex; align-items: center; gap: 6px; padding: 6px 8px; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--background); box-shadow: var(--shadow-resting); animation: ke-in 0.28s var(--ease-settle); }\n  [data-slot=\"key-echo\"] [data-slot=\"ke-times\"] { font-size: 12px; font-variant-numeric: tabular-nums; color: var(--muted-foreground); animation: ke-bump 0.25s var(--ease-settle); }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"key-echo\"] :is([data-slot=\"ke-combo\"], [data-slot=\"ke-times\"]) { animation: none; }\n  }\n  @keyframes ke-in { from { opacity: 0; translate: 0 8px; scale: 0.94; } }\n  @keyframes ke-bump { from { scale: 1.3; } }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"surfaces","docs":"/docs/components/key-echo"}}