# Kbd

> Keyboard keys and shortcuts that glow when pressed.

Category: Data Display. Docs: https://get-neonkit.vercel.app/components/kbd

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/kbd.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/kbd`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Kbd, KbdCombo } from "@/components/ui/kbd"

<KbdCombo keys={["⌘", "K"]} listen />
<Kbd listen="Shift">Shift</Kbd>
```

## Examples

### Command menu hint

With listen, each key lights up while you hold the real one.

```tsx
import { Search } from "lucide-react";
import { KbdCombo } from "@/components/ui/kbd";

export default function KbdDemo() {
  return (
    <div className="flex w-full max-w-xs flex-col items-center gap-4">
      <button
        type="button"
        className="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-lg border border-white/10 bg-white/[0.03] pl-3 pr-2 text-sm text-white/40 outline-none transition-colors duration-150 hover:border-white/15 hover:text-white/60 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent,34_211_238)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]"
      >
        <Search aria-hidden className="size-4" />
        <span className="flex-1 text-left">Search components…</span>
        <KbdCombo keys={["⌘", "K"]} listen separator={null} />
      </button>
      <p className="text-xs text-white/40">Hold ⌘ and press K. The keys light up as you type.</p>
    </div>
  );
}
```

### Shortcut sheet

separator accepts any node, or null for keys side by side.

```tsx
import { KbdCombo } from "@/components/ui/kbd";

const shortcuts = [
  { action: "Open command menu", keys: ["⌘", "K"] },
  { action: "Toggle sidebar", keys: ["⌘", "B"] },
  { action: "Copy component source", keys: ["⌘", "⇧", "C"] },
  { action: "Next example", keys: ["J"] },
  { action: "Previous example", keys: ["K"] },
  { action: "Go to settings", keys: ["G", "S"], then: true },
  { action: "Close dialog", keys: ["Esc"] },
];

export default function KbdShortcuts() {
  return (
    <div className="w-full max-w-sm overflow-hidden rounded-xl border border-white/10 bg-white/[0.02]">
      <table className="w-full border-collapse text-sm">
        <caption className="sr-only">Keyboard shortcuts</caption>
        <thead>
          <tr className="border-b border-white/[0.06] text-left text-xs text-white/40">
            <th scope="col" className="px-4 py-2.5 font-medium">
              Action
            </th>
            <th scope="col" className="px-4 py-2.5 text-right font-medium">
              Shortcut
            </th>
          </tr>
        </thead>
        <tbody className="divide-y divide-white/[0.06]">
          {shortcuts.map((shortcut) => (
            <tr key={shortcut.action}>
              <td className="px-4 py-2.5 text-white/70">{shortcut.action}</td>
              <td className="px-4 py-2.5 text-right">
                <KbdCombo
                  keys={shortcut.keys}
                  size="sm"
                  separator={shortcut.then ? <span className="px-0.5 text-white/40">then</span> : null}
                />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

### Listening keys

listen takes a KeyboardEvent.key value and matches it case-insensitively.

```tsx
import { Kbd } from "@/components/ui/kbd";

const keys = [
  { label: "⇧ shift", listen: "Shift", width: "w-24 justify-start" },
  { label: "⌃", listen: "Control", width: "w-11" },
  { label: "⌥", listen: "Alt", width: "w-11" },
  { label: "⌘", listen: "Meta", width: "w-14" },
  { label: "", listen: " ", width: "w-40" },
];

export default function KbdListen() {
  return (
    <div className="flex flex-col items-center gap-6">
      <div className="flex items-center gap-1.5" aria-hidden>
        {keys.map((key) => (
          <Kbd key={key.listen} size="lg" listen={key.listen} className={`h-11 ${key.width}`}>
            {key.label}
          </Kbd>
        ))}
      </div>
      <p className="text-sm text-white/45">
        Press <span className="text-white/80">Shift</span>, any modifier or the space bar.
      </p>
    </div>
  );
}
```

### Sizes and pressed state

pressed forces the lit state, for tutorials and game controls.

```tsx
import { Kbd, KbdCombo } from "@/components/ui/kbd";

export default function KbdSizes() {
  return (
    <div className="flex flex-col items-center gap-8">
      <div className="flex items-center gap-6">
        <KbdCombo keys={["⌘", "K"]} size="sm" />
        <KbdCombo keys={["⌘", "K"]} size="md" />
        <KbdCombo keys={["⌘", "K"]} size="lg" />
      </div>
      <div className="grid grid-cols-3 gap-1.5">
        <Kbd size="lg" pressed className="col-start-2">
          W
        </Kbd>
        <Kbd size="lg">A</Kbd>
        <Kbd size="lg">S</Kbd>
        <Kbd size="lg" pressed>
          D
        </Kbd>
      </div>
    </div>
  );
}
```

## API reference

### Kbd

Renders a kbd element and accepts every native attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 20, 24 or 32px tall. |
| `pressed` | `boolean` | `false` | Forces the lit, pushed-down state. |
| `listen` | `string` | — | KeyboardEvent.key to watch, like "k", "Shift", "Meta" or "Enter". Lights up while held. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### KbdCombo

A kbd that wraps one Kbd per key, as HTML recommends for key combinations.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `keys` | `string[]` | — | Key labels in order. ⌘ ⌥ ⇧ ⌃ ↵ and arrows get spoken names. |
| `listen` | `boolean` | `false` | Each key listens for itself. ⌘ maps to Meta, ⌥ to Alt, ⇧ to Shift, ⌃ to Control, ↵ to Enter, Esc to Escape. |
| `separator` | `ReactNode` | `"+"` | Rendered between keys. Pass null for none. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Size of every key. |
| `color` | `NeonColor` | `inherited` | Color of every key. |

## Accessibility

- Kbd is display only. listen never prevents default or triggers anything, so wire the real shortcut yourself.
- Symbol keys in KbdCombo carry a screen reader label, so ⌘ is read as "Command".
- Held keys clear when the window loses focus, and releasing ⌘ clears the rest because macOS drops their keyup events.

## Source

`components/ui/kbd.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const sizes = {
  sm: { key: "h-5 min-w-5 rounded-[5px] px-1 text-[11px]", combo: "gap-1 text-[11px]" },
  md: { key: "h-6 min-w-6 rounded-md px-1.5 text-xs", combo: "gap-1 text-xs" },
  lg: { key: "h-8 min-w-8 rounded-lg px-2 text-sm", combo: "gap-1.5 text-sm" },
} as const;

// Pressing lights up fast; releasing fades slowly, like a tube cooling down.
const states = {
  rest: "border-white/10 bg-white/[0.04] text-white/70 shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_-2px_0_rgb(0_0_0/0.45)] duration-300",
  lit: "translate-y-px border-[rgb(var(--neon)/0.7)] bg-[rgb(var(--neon)/0.12)] text-[rgb(var(--neon))] shadow-[inset_0_1px_0_rgb(var(--neon)/0.2),inset_0_-1px_0_rgb(0_0_0/0.35),0_0_14px_rgb(var(--neon)/0.45)] [text-shadow:0_0_8px_rgb(var(--neon)/0.8)] duration-75",
} as const;

function matchesKey(event: KeyboardEvent, key: string) {
  const target = key.toLowerCase();
  if (event.key.toLowerCase() === target) return true;
  // Option on macOS rewrites event.key (⌥K is "˚"), so fall back to the physical key.
  if (/^[a-z]$/.test(target)) return event.code === `Key${target.toUpperCase()}`;
  if (/^[0-9]$/.test(target)) return event.code === `Digit${target}`;
  return false;
}

function useKeyHeld(key?: string) {
  const [held, setHeld] = React.useState(false);

  React.useEffect(() => {
    if (!key) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (matchesKey(event, key)) setHeld(true);
    };
    const onKeyUp = (event: KeyboardEvent) => {
      // macOS drops keyup for other keys while ⌘ is down, so releasing ⌘ clears them too.
      if (matchesKey(event, key) || event.key === "Meta") setHeld(false);
    };
    const release = () => setHeld(false);

    window.addEventListener("keydown", onKeyDown);
    window.addEventListener("keyup", onKeyUp);
    window.addEventListener("blur", release);
    return () => {
      window.removeEventListener("keydown", onKeyDown);
      window.removeEventListener("keyup", onKeyUp);
      window.removeEventListener("blur", release);
    };
  }, [key]);

  return Boolean(key) && held;
}

export interface KbdProps extends Omit<React.HTMLAttributes<HTMLElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: keyof typeof sizes;
  /** Forces the lit, pushed-down state. */
  pressed?: boolean;
  /** A KeyboardEvent.key value ("k", "Shift", "Meta"). The key lights up while it is held. */
  listen?: string;
}

const Kbd = React.forwardRef<HTMLElement, KbdProps>(
  ({ className, style, color, size = "md", pressed = false, listen, ...props }, ref) => {
    const held = useKeyHeld(listen);
    const lit = pressed || held;

    return (
      <kbd
        ref={ref}
        data-state={lit ? "pressed" : "idle"}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "inline-flex shrink-0 items-center justify-center whitespace-nowrap border pb-0.5 font-sans font-medium leading-none",
          "transition-[color,background-color,border-color,box-shadow,text-shadow,transform] ease-out motion-reduce:transition-none",
          "[&>svg]:size-[1.1em] [&>svg]:shrink-0",
          sizes[size].key,
          lit ? states.lit : states.rest,
          className,
        )}
        {...props}
      />
    );
  },
);
Kbd.displayName = "Kbd";

const keyNames: Record<string, string> = {
  "⌘": "Meta",
  Cmd: "Meta",
  "⌥": "Alt",
  Option: "Alt",
  "⇧": "Shift",
  "⌃": "Control",
  Ctrl: "Control",
  "↵": "Enter",
  "⏎": "Enter",
  Esc: "Escape",
  "⌫": "Backspace",
  "⇥": "Tab",
  "↑": "ArrowUp",
  "↓": "ArrowDown",
  "←": "ArrowLeft",
  "→": "ArrowRight",
  Space: " ",
};

const keyLabels: Record<string, string> = {
  "⌘": "Command",
  "⌥": "Option",
  "⇧": "Shift",
  "⌃": "Control",
  "↵": "Enter",
  "⏎": "Enter",
  "⌫": "Backspace",
  "⇥": "Tab",
  "↑": "Up arrow",
  "↓": "Down arrow",
  "←": "Left arrow",
  "→": "Right arrow",
};

export interface KbdComboProps extends Omit<React.HTMLAttributes<HTMLElement>, "color"> {
  /** Key labels in order, e.g. ["⌘", "K"]. Symbols get a spoken name for screen readers. */
  keys: string[];
  /** Lights each key while its real key is held. */
  listen?: boolean;
  size?: keyof typeof sizes;
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  /** Rendered between keys. Pass null for none. */
  separator?: React.ReactNode;
}

const KbdCombo = React.forwardRef<HTMLElement, KbdComboProps>(
  ({ className, style, keys, listen = false, size = "md", color, separator = "+", ...props }, ref) => (
    <kbd
      ref={ref}
      style={neonVars(color, style)}
      className={cn("inline-flex items-center font-sans", sizes[size].combo, className)}
      {...props}
    >
      {keys.map((key, index) => (
        <React.Fragment key={`${key}-${index}`}>
          {index > 0 && separator != null && separator !== false && (
            <span className="text-white/30">{separator}</span>
          )}
          <Kbd size={size} listen={listen ? (keyNames[key] ?? key) : undefined}>
            {keyLabels[key] ? (
              <>
                <span aria-hidden>{key}</span>
                <span className="sr-only">{keyLabels[key]}</span>
              </>
            ) : (
              key
            )}
          </Kbd>
        </React.Fragment>
      ))}
    </kbd>
  ),
);
KbdCombo.displayName = "KbdCombo";

export { Kbd, KbdCombo };
```
