Data Display

Kbd

A keycap primitive with a ledge-shadow treatment that physically depresses on matching keypresses, plus a "then" sequence wrapper for chorded shortcuts.

Install

npx shadcn@latest add @paragon/kbd

kbd.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/** Display glyphs and shorthand → KeyboardEvent.key values. */
const KEY_ALIASES: Record<string, string> = {
  "⌘": "meta",
  cmd: "meta",
  command: "meta",
  "⌥": "alt",
  "⎇": "alt",
  option: "alt",
  "⇧": "shift",
  "⌃": "control",
  ctrl: "control",
  "↵": "enter",
  "⏎": "enter",
  return: "enter",
  esc: "escape",
  "␣": " ",
  space: " ",
  "↑": "arrowup",
  "↓": "arrowdown",
  "←": "arrowleft",
  "→": "arrowright",
  "⌫": "backspace",
  "⇥": "tab",
};

function resolveKeyName(
  keyName: string | undefined,
  children: React.ReactNode,
): string | null {
  const raw = keyName ?? (typeof children === "string" ? children : null);
  if (!raw) return null;
  const lower = raw.trim().toLowerCase();
  return KEY_ALIASES[lower] ?? lower;
}

export interface KbdProps extends React.ComponentProps<"kbd"> {
  /** Depress the keycap when the matching physical key is held. */
  listen?: boolean;
  /**
   * `KeyboardEvent.key` to match when listening. Defaults to the string
   * child — glyphs like ⌘/⇧/↵ resolve automatically.
   */
  keyName?: string;
  /** Disables the pressed-state motion. */
  static?: boolean;
}

/**
 * A keycap. The realistic treatment is two shadows: a hairline ring plus a
 * 1.5px ledge under the bottom edge. Pressing (via `listen` and the real
 * keyboard) sinks the cap by exactly the ledge height so it reads as
 * physically depressed. Interruptible transition — mashing keys retargets.
 */
export function Kbd({
  listen = false,
  keyName,
  static: isStatic = false,
  className,
  children,
  ...props
}: KbdProps) {
  const [pressed, setPressed] = React.useState(false);
  const resolved = resolveKeyName(keyName, children);

  React.useEffect(() => {
    if (!listen || !resolved) return;
    const down = (event: KeyboardEvent) => {
      if (event.key.toLowerCase() === resolved) setPressed(true);
    };
    const up = (event: KeyboardEvent) => {
      if (event.key.toLowerCase() === resolved) setPressed(false);
    };
    const clear = () => setPressed(false);
    window.addEventListener("keydown", down);
    window.addEventListener("keyup", up);
    window.addEventListener("blur", clear);
    return () => {
      window.removeEventListener("keydown", down);
      window.removeEventListener("keyup", up);
      window.removeEventListener("blur", clear);
    };
  }, [listen, resolved]);

  const isDown = pressed && !isStatic;

  return (
    <kbd
      data-slot="kbd"
      data-pressed={isDown || undefined}
      className={cn(
        "inline-flex h-5 min-w-5 items-center justify-center rounded-[5px] bg-card px-1 font-sans text-[11px] font-medium text-muted-foreground tabular-nums select-none",
        "transition-[translate,box-shadow,background-color,color] duration-100 ease-(--ease-out)",
        isDown
          ? "translate-y-[1.5px] bg-muted text-foreground shadow-[0_0_0_1px_var(--color-border)] motion-reduce:translate-y-0"
          : "shadow-[0_0_0_1px_var(--color-border),0_1.5px_0_var(--color-border)]",
        className,
      )}
      {...props}
    >
      {children}
    </kbd>
  );
}

export interface KbdSequenceProps extends React.ComponentProps<"span"> {
  /** Word between keys. */
  separator?: string;
}

/**
 * A key sequence — "G then D" — for chorded shortcuts. Pass `<Kbd>`
 * children; the separator is interleaved and hidden from copy/paste noise
 * with lowered contrast, not removed from the accessibility tree.
 */
export function KbdSequence({
  separator = "then",
  className,
  children,
  ...props
}: KbdSequenceProps) {
  const items = React.Children.toArray(children);
  return (
    <span
      data-slot="kbd-sequence"
      className={cn("inline-flex items-center gap-1.5", className)}
      {...props}
    >
      {items.map((child, i) => (
        <React.Fragment key={i}>
          {i > 0 && (
            <span className="text-[10px] text-muted-foreground/70">
              {separator}
            </span>
          )}
          {child}
        </React.Fragment>
      ))}
    </span>
  );
}