Keyboard Button
Buttons

Keyboard Button

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.

Install

npx shadcn@latest add @paragon/keyboard-button

Also installs: button, kbd

keyboard-button.tsx

"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>
  );
}