A keycap primitive with a ledge-shadow treatment that physically depresses on matching keypresses, plus a "then" sequence wrapper for chorded shortcuts.
npx shadcn@latest add @paragon/kbd"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>
);
}