A button with its shortcut built in as live keycaps: physical keys sink the caps, and when the full chord lands the button depresses and clicks through one code path.
npx shadcn@latest add @paragon/keyboard-buttonAlso installs: button, kbd
"use client";
import * as React from "react";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/registry/paragon/ui/button";
import { Kbd } from "@/registry/paragon/ui/kbd";
/** Display tokens → the KeyboardEvent fields they assert. */
const MODIFIER_ALIASES: Record<string, "meta" | "ctrl" | "alt" | "shift"> = {
"⌘": "meta",
cmd: "meta",
command: "meta",
meta: "meta",
"⌃": "ctrl",
ctrl: "ctrl",
control: "ctrl",
"⌥": "alt",
"⎇": "alt",
alt: "alt",
option: "alt",
"⇧": "shift",
shift: "shift",
};
const KEY_ALIASES: Record<string, string> = {
"↵": "enter",
"⏎": "enter",
return: "enter",
esc: "escape",
"␣": " ",
space: " ",
"↑": "arrowup",
"↓": "arrowdown",
"←": "arrowleft",
"→": "arrowright",
"⌫": "backspace",
"⇥": "tab",
};
interface ParsedShortcut {
meta: boolean;
ctrl: boolean;
alt: boolean;
shift: boolean;
key: string | null;
}
function parseShortcut(keys: string[]): ParsedShortcut {
const parsed: ParsedShortcut = {
meta: false,
ctrl: false,
alt: false,
shift: false,
key: null,
};
for (const raw of keys) {
const token = raw.trim().toLowerCase();
const modifier = MODIFIER_ALIASES[token];
if (modifier) parsed[modifier] = true;
else parsed.key = KEY_ALIASES[token] ?? token;
}
return parsed;
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
);
}
export interface KeyboardButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
/**
* Shortcut as display tokens, e.g. `["⌘", "K"]` or `["shift", "D"]`.
* Glyphs and names both resolve; the last non-modifier token is the key.
*/
keys: string[];
/** Listen globally and fire the button when the shortcut lands. */
listen?: boolean;
/** Fire even while an input, textarea, or contenteditable has focus. */
allowWhileTyping?: boolean;
/** Call preventDefault on the matched keydown. */
preventDefault?: boolean;
/** Disables the press-scale and keycap sink; the shortcut still fires. */
static?: boolean;
}
/**
* A button with its keyboard shortcut built in — rendered as live keycaps
* that sink when the physical keys go down. When the full chord lands, the
* button itself depresses and clicks, so pointer and keyboard share one
* code path. Editable targets are ignored by default, and per the house
* frequency rule the keyboard path adds no extra animation beyond the
* 100ms press.
*/
export function KeyboardButton({
keys,
listen = true,
allowWhileTyping = false,
preventDefault = true,
static: isStatic = false,
variant = "outline",
size,
className,
children,
disabled,
...props
}: KeyboardButtonProps) {
const ref = React.useRef<HTMLButtonElement>(null);
const [chordDown, setChordDown] = React.useState(false);
const releaseTimer = React.useRef<ReturnType<typeof setTimeout>>(null);
const shortcut = React.useMemo(() => parseShortcut(keys), [keys]);
React.useEffect(() => {
if (!listen || disabled || !shortcut.key) return;
const down = (event: KeyboardEvent) => {
if (event.repeat) return;
if (event.key.toLowerCase() !== shortcut.key) return;
if (
event.metaKey !== shortcut.meta ||
event.ctrlKey !== shortcut.ctrl ||
event.altKey !== shortcut.alt ||
event.shiftKey !== shortcut.shift
) {
return;
}
if (!allowWhileTyping && isEditableTarget(event.target)) return;
if (preventDefault) event.preventDefault();
setChordDown(true);
ref.current?.click();
// Safety valve: macOS swallows keyups while ⌘ is held.
if (releaseTimer.current) clearTimeout(releaseTimer.current);
releaseTimer.current = setTimeout(() => setChordDown(false), 400);
};
const up = () => setChordDown(false);
const clear = () => setChordDown(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);
if (releaseTimer.current) clearTimeout(releaseTimer.current);
};
}, [listen, disabled, shortcut, allowWhileTyping, preventDefault]);
return (
<button
ref={ref}
type="button"
data-slot="keyboard-button"
data-chord-down={(chordDown && !isStatic) || undefined}
disabled={disabled}
aria-keyshortcuts={
shortcut.key
? [
shortcut.meta && "Meta",
shortcut.ctrl && "Control",
shortcut.alt && "Alt",
shortcut.shift && "Shift",
shortcut.key.length === 1
? shortcut.key.toUpperCase()
: shortcut.key,
]
.filter(Boolean)
.join("+")
: undefined
}
className={cn(
buttonVariants({ variant, size }),
!isStatic && "active:not-disabled:scale-[0.97]",
chordDown && !isStatic && "scale-[0.97]",
className,
)}
{...props}
>
{children}
<span className="ml-0.5 -mr-1 inline-flex shrink-0 gap-1">
{keys.map((token, i) => (
<Kbd key={`${token}-${i}`} listen={listen} static={isStatic}>
{token}
</Kbd>
))}
</span>
</button>
);
}