"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export interface KbdShortcutProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
/**
* The shortcut. Keys in a chord are joined with "+", steps of a sequence with spaces:
* "mod+k", "shift+?", "g i" or "g then i". `mod` is ⌘ on Apple platforms and Ctrl elsewhere.
*/
shortcut?: string;
/**
* When keys light up and `onTrigger` fires: while the component has focus, on the whole window
* (typing in inputs is ignored), or never (display only).
*/
listen?: "focus" | "window" | "none";
/** Called when the full shortcut (every step of a sequence) has been pressed. */
onTrigger?: (event: KeyboardEvent) => void;
/** Modifier glyphs: auto-detected after hydration (Ctrl on the server), or forced. */
platform?: "auto" | "mac" | "other";
size?: "sm" | "md" | "lg";
variant?: "raised" | "soft" | "outline";
/** Show "+" between the keys of a chord. */
separator?: boolean;
/** Time allowed between the steps of a sequence, in ms. */
sequenceTimeout?: number;
/** Pressed-key tint, completed steps and the trigger flash. */
accent?: string;
}
type Modifier = "meta" | "ctrl" | "alt" | "shift";
interface Key {
/** Normalised id compared with the pressed key ("k", "meta", "up", "?" …). */
id: string;
modifier?: Modifier;
}
const MODIFIER_ALIASES: Record<string, Modifier | "mod"> = {
mod: "mod",
cmd: "meta",
command: "meta",
meta: "meta",
super: "meta",
win: "meta",
ctrl: "ctrl",
control: "ctrl",
alt: "alt",
option: "alt",
opt: "alt",
shift: "shift",
};
const KEY_ALIASES: Record<string, string> = {
return: "enter",
escape: "esc",
del: "delete",
arrowup: "up",
arrowdown: "down",
arrowleft: "left",
arrowright: "right",
plus: "+",
spacebar: "space",
" ": "space",
};
const MAC_GLYPHS: Record<string, string> = {
meta: "⌘",
ctrl: "⌃",
alt: "⌥",
shift: "⇧",
enter: "↵",
esc: "esc",
tab: "⇥",
backspace: "⌫",
delete: "⌦",
space: "Space",
up: "↑",
down: "↓",
left: "←",
right: "→",
};
const OTHER_LABELS: Record<string, string> = {
meta: "Win",
ctrl: "Ctrl",
alt: "Alt",
shift: "Shift",
enter: "Enter",
esc: "Esc",
tab: "Tab",
backspace: "Backspace",
delete: "Del",
space: "Space",
up: "↑",
down: "↓",
left: "←",
right: "→",
pageup: "PgUp",
pagedown: "PgDn",
home: "Home",
end: "End",
};
const SPOKEN: Record<string, string> = {
ctrl: "Control",
shift: "Shift",
enter: "Enter",
esc: "Escape",
tab: "Tab",
backspace: "Backspace",
delete: "Delete",
space: "Space",
up: "Up Arrow",
down: "Down Arrow",
left: "Left Arrow",
right: "Right Arrow",
pageup: "Page Up",
pagedown: "Page Down",
home: "Home",
end: "End",
"/": "Slash",
"?": "Question Mark",
",": "Comma",
".": "Period",
";": "Semicolon",
"'": "Quote",
"[": "Left Bracket",
"]": "Right Bracket",
"\\": "Backslash",
"-": "Minus",
"=": "Equals",
"+": "Plus",
"`": "Backtick",
};
/** Parses "mod+shift+p" / "g then i" into steps of keys; `mod` resolves to meta or ctrl. */
function parse(shortcut: string, mac: boolean): Key[][] {
return shortcut
.trim()
.split(/\s+/)
.filter((step) => step && step.toLowerCase() !== "then")
.map((step) => {
// "ctrl++" → ["ctrl", "+"]
const tokens = step.endsWith("++") ? [...step.slice(0, -2).split("+"), "+"] : step === "+" ? ["+"] : step.split("+");
return tokens
.filter(Boolean)
.map((raw) => {
const token = raw.toLowerCase();
const alias = MODIFIER_ALIASES[token];
if (alias) {
const modifier = alias === "mod" ? (mac ? "meta" : "ctrl") : alias;
return { id: modifier, modifier };
}
return { id: KEY_ALIASES[token] ?? token };
})
// Modifiers first, in the conventional order for the platform.
.sort((a, b) => order(a, mac) - order(b, mac));
})
.filter((keys) => keys.length > 0);
}
function order(key: Key, mac: boolean) {
if (!key.modifier) return 9;
return (mac ? ["ctrl", "alt", "shift", "meta"] : ["ctrl", "meta", "alt", "shift"]).indexOf(key.modifier);
}
function label(key: Key, mac: boolean) {
const table = mac ? MAC_GLYPHS : OTHER_LABELS;
if (table[key.id]) return table[key.id];
if (/^f\d{1,2}$/.test(key.id)) return key.id.toUpperCase();
return key.id.length === 1 ? key.id.toUpperCase() : key.id.charAt(0).toUpperCase() + key.id.slice(1);
}
function spoken(key: Key, mac: boolean) {
if (key.id === "meta") return mac ? "Command" : "Windows";
if (key.id === "alt") return mac ? "Option" : "Alt";
if (key.id === "enter" && mac) return "Return";
return SPOKEN[key.id] ?? (key.id.length === 1 ? key.id.toUpperCase() : label(key, mac));
}
/** Normalised id of the main (non-modifier) key of an event, or the modifier itself. */
function eventKeyId(event: KeyboardEvent): string {
if (event.key === "Meta" || event.key === "OS") return "meta";
if (event.key === "Control") return "ctrl";
if (event.key === "Alt" || event.key === "AltGraph") return "alt";
if (event.key === "Shift") return "shift";
// Physical letters / digits survive Shift and ⌥, which change event.key.
if (/^Key[A-Z]$/.test(event.code)) return event.code.slice(3).toLowerCase();
if (/^Digit\d$/.test(event.code)) return event.code.slice(5);
const key = event.key.toLowerCase();
return KEY_ALIASES[key] ?? key;
}
function chordMatches(keys: Key[], event: KeyboardEvent) {
const main = keys.find((k) => !k.modifier);
const id = eventKeyId(event);
if (!main || main.id !== id) return false;
const wants = (m: Modifier) => keys.some((k) => k.modifier === m);
if (event.metaKey !== wants("meta") || event.ctrlKey !== wants("ctrl") || event.altKey !== wants("alt")) return false;
// Symbols like "?" already imply Shift on most layouts, so Shift only has to match when listed or for letters / digits.
const symbol = id.length === 1 && !/[a-z0-9]/.test(id);
return symbol && !wants("shift") ? true : event.shiftKey === wants("shift");
}
function isEditable(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName);
}
function detectMac() {
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? "");
}
const noopSubscribe = () => () => {};
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const SIZES = {
sm: { cap: "h-5 min-w-5 rounded-[5px] px-1 text-[11px]", gap: "gap-0.5", plus: "text-[10px]", then: "text-[9px] px-0.5" },
md: { cap: "h-7 min-w-7 rounded-md px-1.5 text-[13px]", gap: "gap-1", plus: "text-[11px]", then: "text-[10px] px-1" },
lg: { cap: "h-11 min-w-11 rounded-[9px] px-2.5 text-[17px]", gap: "gap-1.5", plus: "text-[13px]", then: "text-[11px] px-1.5" },
} as const;
const VARIANTS = {
raised:
"border border-zinc-900/[0.13] bg-[linear-gradient(180deg,#fff,#f4f4f5)] text-zinc-700 shadow-[inset_0_-2px_0_rgb(24_24_27/0.08),0_1px_1px_rgb(24_24_27/0.06)] dark:border-white/[0.09] dark:bg-[linear-gradient(180deg,#2a2e33,#212529)] dark:text-zinc-200 dark:shadow-[inset_0_-2px_0_rgb(0_0_0/0.35),inset_0_1px_0_rgb(255_255_255/0.05)]",
soft: "border border-transparent bg-zinc-900/[0.06] text-zinc-700 dark:bg-white/[0.08] dark:text-zinc-200",
outline: "border border-zinc-900/20 bg-transparent text-zinc-700 dark:border-white/20 dark:text-zinc-200",
} as const;
/**
* Renders a keyboard shortcut as keycaps and lights each key up while it is physically held, so
* people can try the shortcut right where it is documented. Modifiers follow the platform (⌘ / Ctrl,
* refined after hydration), sequences such as "g then i" show their progress, `onTrigger` fires when
* the whole shortcut is pressed, and screen readers hear "Command K" rather than glyphs.
*/
export function KbdShortcut({
shortcut = "mod+k",
listen = "focus",
onTrigger,
platform = "auto",
size = "md",
variant = "raised",
separator = false,
sequenceTimeout = 1000,
accent = "#FF7448",
className = "",
style,
onKeyDown,
onKeyUp,
onBlur,
...props
}: KbdShortcutProps) {
const detected = React.useSyncExternalStore(noopSubscribe, detectMac, () => false);
const mac = platform === "auto" ? detected : platform === "mac";
const reduceMotion = usePrefersReducedMotion();
const steps = parse(shortcut, mac);
const [held, setHeld] = React.useState<string[]>([]);
const [step, setStep] = React.useState(0);
const [fired, setFired] = React.useState(0);
const timer = React.useRef(0);
const s = SIZES[size] ?? SIZES.md;
const spokenText = steps.map((keys) => keys.map((k) => spoken(k, mac)).join(" ")).join(", then ");
const resetSequence = () => {
window.clearTimeout(timer.current);
setStep(0);
};
/**
* Shared key-down logic for focus and window listening. Returns "trigger" when the shortcut
* completed, "part" when the key belongs to it (a modifier or an earlier step) and null otherwise.
*/
const handleDown = (event: KeyboardEvent): "trigger" | "part" | null => {
const id = eventKeyId(event);
setHeld((prev) => (prev.includes(id) ? prev : [...prev, id]));
if (steps.length === 0) return null;
if (["meta", "ctrl", "alt", "shift"].includes(id) || event.repeat) {
return steps[step]?.some((k) => k.id === id) ? "part" : null;
}
const at = chordMatches(steps[step] ?? [], event) ? step : chordMatches(steps[0], event) ? 0 : -1;
window.clearTimeout(timer.current);
if (at === -1) {
setStep(0);
return null;
}
if (at === steps.length - 1) {
setStep(0);
setFired((n) => n + 1);
onTrigger?.(event);
return "trigger";
}
setStep(at + 1);
timer.current = window.setTimeout(() => setStep(0), sequenceTimeout);
return "part";
};
const handleUp = (event: KeyboardEvent) => {
const id = eventKeyId(event);
// macOS swallows key-up events of keys pressed while ⌘ is held: releasing ⌘ releases everything.
setHeld((prev) => (id === "meta" ? prev.filter((k) => ["ctrl", "alt", "shift"].includes(k)) : prev.filter((k) => k !== id)));
};
const release = () => setHeld([]);
// Window listening calls the latest handlers without re-subscribing on every render.
const handlers = React.useRef({ handleDown, handleUp, release });
React.useEffect(() => {
handlers.current = { handleDown, handleUp, release };
});
React.useEffect(() => {
if (listen !== "window") return;
const down = (event: KeyboardEvent) => {
if (isEditable(event.target)) return;
// Only the completing key is consumed; earlier steps (like "g") stay ordinary key presses.
if (handlers.current.handleDown(event) === "trigger") event.preventDefault();
};
const up = (event: KeyboardEvent) => handlers.current.handleUp(event);
const blur = () => handlers.current.release();
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("blur", blur);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
window.removeEventListener("blur", blur);
};
}, [listen]);
React.useEffect(() => () => window.clearTimeout(timer.current), []);
const focusable = listen === "focus";
return (
<span
role={focusable ? "group" : undefined}
aria-roledescription={focusable ? "keyboard shortcut" : undefined}
aria-label={focusable ? spokenText : undefined}
tabIndex={focusable ? 0 : undefined}
data-listening={listen}
className={`relative inline-flex items-center rounded-lg align-middle outline-hidden ${s.gap} ${focusable ? "cursor-default focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[var(--kbd-accent)]" : ""} ${className}`}
style={{ ["--kbd-accent" as string]: accent, ...style }}
onKeyDown={(event) => {
onKeyDown?.(event);
if (!focusable || event.defaultPrevented) return;
if (event.key === "Tab" && !steps.some((keys) => keys.some((k) => k.id === "tab"))) return;
// Keys that belong to the shortcut are consumed here so page-level shortcuts don't also fire.
if (handleDown(event.nativeEvent)) {
event.preventDefault();
event.stopPropagation();
}
}}
onKeyUp={(event) => {
onKeyUp?.(event);
if (focusable) handleUp(event.nativeEvent);
}}
onBlur={(event) => {
onBlur?.(event);
release();
resetSequence();
}}
{...props}
>
{focusable ? null : <span className="sr-only">{spokenText}</span>}
{steps.map((keys, stepIndex) => (
<React.Fragment key={stepIndex}>
{stepIndex > 0 ? (
<span aria-hidden="true" className={`font-mono uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500 ${s.then}`}>
then
</span>
) : null}
<kbd aria-hidden="true" className={`inline-flex items-center ${s.gap}`}>
{keys.map((key, i) => {
const done = stepIndex < step;
const down = listen !== "none" && stepIndex === step && held.includes(key.id);
return (
<React.Fragment key={`${key.id}-${i}`}>
{i > 0 && separator ? <span className={`text-zinc-400 dark:text-zinc-500 ${s.plus}`}>+</span> : null}
<kbd
data-down={down || undefined}
data-done={done || undefined}
className={`inline-flex select-none items-center justify-center font-mono font-medium leading-none tracking-tight transition-[translate,background-color,border-color,box-shadow,color] duration-75 ${s.cap} ${VARIANTS[variant] ?? VARIANTS.raised} data-[done]:border-[color-mix(in_oklab,var(--kbd-accent)_70%,transparent)] data-[done]:text-zinc-950 dark:data-[done]:text-white data-[down]:translate-y-px data-[down]:border-[color-mix(in_oklab,var(--kbd-accent)_75%,transparent)] data-[down]:bg-[color-mix(in_oklab,var(--kbd-accent)_22%,transparent)] data-[down]:bg-none data-[down]:text-zinc-950 data-[down]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--kbd-accent)_18%,transparent)] motion-reduce:data-[down]:translate-y-0 dark:data-[down]:text-white`}
>
{label(key, mac)}
</kbd>
</React.Fragment>
);
})}
</kbd>
</React.Fragment>
))}
<AnimatePresence>
{fired > 0 ? (
<motion.span
key={fired}
aria-hidden="true"
className="pointer-events-none absolute -inset-1.5 rounded-[10px] border border-[var(--kbd-accent)]"
initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.96 }}
animate={{ opacity: [0, 0.9, 0], scale: reduceMotion ? 1 : [0.96, 1, 1.05] }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.4 : 0.5, times: [0, 0.2, 1], ease: "easeOut" }}
/>
) : null}
</AnimatePresence>
</span>
);
}