Shortcut button
A button with a kbd hint that also fires when its key combination is pressed.
npx shadcn@latest add https://ui.goodtabs.co/r/shortcut-button.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/shortcut-button
Click or press the keys
Search on mod+k. The hint shows Cmd on a Mac and Ctrl elsewhere.
Single key
Press S or ?
Plain letters and shift combinations, on the secondary and ghost variants.
In a toolbar row
12 open issues, 4 assigned to you
Three small ghost buttons with icons, each on its own key.
Usage
import { ShortcutButton } from "@/components/ui/shortcut-button"
<ShortcutButton shortcut="mod+k" onTrigger={() => setOpen(true)}>
Search
</ShortcutButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| shortcut | string | The combination, like "mod+k", "s" or "shift+/". mod is Cmd on Mac, Ctrl elsewhere. | |
| onTrigger | () => void | Fires on click and when the shortcut is pressed anywhere on the page. | |
| variant | Button variant | "outline" | Any shadcn button variant. The kbd adapts on the solid default variant. |
| size | Button size | "default" | Any non-icon size; the hint needs room next to the label. |
| children | ReactNode | The label, optionally with an icon marked data-icon="inline-start". |
Source
shortcut-button/shortcut-button.tsx
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";
type Modifier = "mod" | "ctrl" | "alt" | "shift" | "meta";
type Parsed = { modifiers: Set<Modifier>; key: string };
const MODIFIER_ALIASES: Record<string, Modifier> = {
mod: "mod",
ctrl: "ctrl",
control: "ctrl",
alt: "alt",
option: "alt",
shift: "shift",
meta: "meta",
cmd: "meta",
command: "meta",
super: "meta",
};
const KEY_ALIASES: Record<string, string> = {
esc: "escape",
return: "enter",
space: " ",
up: "arrowup",
down: "arrowdown",
left: "arrowleft",
right: "arrowright",
};
/** Physical key codes for keys whose event.key changes with shift, so shift+/ matches on any layout. */
const KEY_CODES: Record<string, string> = {
"/": "Slash",
".": "Period",
",": "Comma",
";": "Semicolon",
"'": "Quote",
"[": "BracketLeft",
"]": "BracketRight",
"\\": "Backslash",
"-": "Minus",
"=": "Equal",
"`": "Backquote",
};
const KEY_LABELS: Record<string, string> = {
escape: "Esc",
enter: "Enter",
" ": "Space",
backspace: "Del",
delete: "Del",
tab: "Tab",
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
};
function parseShortcut(shortcut: string): Parsed {
const parts = shortcut
.toLowerCase()
.split("+")
.map((p) => p.trim())
.filter(Boolean);
const modifiers = new Set<Modifier>();
let key = "";
for (const part of parts) {
const modifier = MODIFIER_ALIASES[part];
if (modifier) modifiers.add(modifier);
else key = KEY_ALIASES[part] ?? part;
}
return { modifiers, key };
}
function keyCode(key: string): string | undefined {
if (KEY_CODES[key]) return KEY_CODES[key];
if (/^[a-z]$/.test(key)) return `Key${key.toUpperCase()}`;
if (/^[0-9]$/.test(key)) return `Digit${key}`;
return undefined;
}
function matches(event: KeyboardEvent, parsed: Parsed, isMac: boolean): boolean {
const want = {
ctrl: parsed.modifiers.has("ctrl") || (parsed.modifiers.has("mod") && !isMac),
meta: parsed.modifiers.has("meta") || (parsed.modifiers.has("mod") && isMac),
alt: parsed.modifiers.has("alt"),
shift: parsed.modifiers.has("shift"),
};
if (event.ctrlKey !== want.ctrl || event.metaKey !== want.meta || event.altKey !== want.alt) return false;
// Shift may change event.key, so a single character key still matches by its code.
const byKey = event.key.toLowerCase() === parsed.key;
const byCode = keyCode(parsed.key) !== undefined && event.code === keyCode(parsed.key);
if (!byKey && !byCode) return false;
if (event.shiftKey === want.shift) return true;
// Characters like ? need shift on most layouts; accept them by key when the shortcut did not ask for shift.
return byKey && !want.shift && parsed.key.length === 1 && !/[a-z0-9]/.test(parsed.key);
}
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return Boolean(target.closest("input, textarea, select, [contenteditable=''], [contenteditable='true']"));
}
function isMacPlatform(): boolean {
return /mac|iphone|ipad|ipod/i.test(navigator.platform);
}
const noSubscribe = () => () => {};
/** null until mounted, so the server markup never guesses the platform. */
function useIsMac(): boolean | null {
return React.useSyncExternalStore(noSubscribe, isMacPlatform, () => null);
}
function modifierLabel(modifier: Modifier, isMac: boolean): string {
switch (modifier) {
case "mod":
return isMac ? "⌘" : "Ctrl";
case "meta":
return isMac ? "⌘" : "Win";
case "ctrl":
return isMac ? "⌃" : "Ctrl";
case "alt":
return isMac ? "⌥" : "Alt";
case "shift":
return "⇧";
}
}
function keyLabel(key: string): string {
return KEY_LABELS[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1));
}
/** The aria-keyshortcuts value, in the names the spec uses. */
function ariaShortcut(parsed: Parsed, isMac: boolean): string {
const order: Modifier[] = ["ctrl", "alt", "shift", "meta"];
const names: Record<Modifier, string> = { ctrl: "Control", alt: "Alt", shift: "Shift", meta: "Meta", mod: "" };
const active = new Set(parsed.modifiers);
if (active.delete("mod")) active.add(isMac ? "meta" : "ctrl");
const key = parsed.key === " " ? "Space" : parsed.key.length === 1 ? parsed.key.toUpperCase() : keyLabel(parsed.key);
return [...order.filter((m) => active.has(m)).map((m) => names[m]), key].join("+");
}
type ShortcutButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick"> & {
/** The key combination, for example "mod+k", "s" or "shift+/". mod is Cmd on Mac and Ctrl elsewhere. */
shortcut: string;
/** Fires on click and when the shortcut is pressed anywhere on the page. */
onTrigger?: () => void;
};
/**
* A button with a kbd hint on the right that also fires on its key combination.
* The listener ignores keys typed into inputs, textareas and contenteditable regions.
*/
export function ShortcutButton({
shortcut,
onTrigger,
variant = "outline",
size = "default",
disabled,
className,
children,
...props
}: ShortcutButtonProps) {
const isMac = useIsMac();
const parsed = React.useMemo(() => parseShortcut(shortcut), [shortcut]);
React.useEffect(() => {
if (disabled || isMac === null) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing || event.repeat) return;
if (isTyping(event.target)) return;
if (!matches(event, parsed, isMac)) return;
event.preventDefault();
onTrigger?.();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [parsed, isMac, disabled, onTrigger]);
const mac = isMac ?? false;
const order: Modifier[] = ["ctrl", "alt", "shift", "mod", "meta"];
const hints = [
...order.filter((m) => parsed.modifiers.has(m)).map((m) => modifierLabel(m, mac)),
keyLabel(parsed.key),
];
return (
<Button
type="button"
variant={variant}
size={size}
disabled={disabled}
aria-keyshortcuts={ariaShortcut(parsed, mac)}
onClick={() => onTrigger?.()}
className={cn("transition-colors", className)}
{...props}
>
{children}
<span aria-hidden="true" className="ml-1 inline-flex items-center gap-0.5">
{hints.map((hint, i) => (
<Kbd
key={i}
className="in-data-[variant=default]:border-primary-foreground/20 in-data-[variant=default]:bg-transparent in-data-[variant=default]:text-primary-foreground/70"
>
{hint}
</Kbd>
))}
</span>
</Button>
);
}
For AI agents
Hand your agent this page, the markdown at /components/shortcut-button/prompt.md, or the Copy AI prompt button above.
When to use
- Opening a command menu, search or new item dialog that power users reach by keyboard
- Toolbar actions in an app view where the hint teaches the shortcut
- Anywhere a shortcut exists and should be discoverable without a help page
When not to use
- For destructive actions; a global key press should never delete anything
- On marketing pages or forms where no one expects shortcuts
- For more than a handful of shortcuts on one screen; register them in one place instead
Agent notes
- onTrigger is a function, so a page that renders it must be a client component; the listener is on window and ignores inputs, textareas, selects and contenteditable
- mod reads navigator.platform after mount; the hint renders Ctrl until then and swaps to the Cmd symbol on a Mac
- Modifiers must match exactly, so mod+k does not fire on mod+shift+k; shift+/ matches by key code, so it works as ? on any layout
- The button sets aria-keyshortcuts and the hint is aria-hidden, so the accessible name is the label alone; disabled turns the listener off