Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
shortcutstringThe combination, like "mod+k", "s" or "shift+/". mod is Cmd on Mac, Ctrl elsewhere.
onTrigger() => voidFires on click and when the shortcut is pressed anywhere on the page.
variantButton variant"outline"Any shadcn button variant. The kbd adapts on the solid default variant.
sizeButton size"default"Any non-icon size; the hint needs room next to the label.
childrenReactNodeThe 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