# Key Combo

> Keycaps for a keyboard shortcut that press down when you hold the real keys, for command menu hints, docs and onboarding.

Category: Inputs · Free · Updated 2026-10-23 · Live demo: https://motionkitui.com/components/key-combo

## When to use it

Shortcut hints beside a search field or command menu, keyboard reference pages, docs and onboarding tours that teach power-user shortcuts.

## How it works

Each key is a kbd element styled as a keycap, with a thicker bottom border for depth. With listen on, keydown and keyup listeners on the window check each label: modifiers such as ⌘, Ctrl and Shift read the event's modifier flags, named keys such as Enter and the arrows match event.key, and letters match case-insensitively. Held keys lose their bottom border and drop 2px, so they look pressed. When every key is held at once, onTrigger fires once, and the caps release if the window loses focus.

## Installation

```bash
npx shadcn@latest add https://motionkitui.com/r/key-combo.json
```

Or copy `components/motionkit/key-combo.tsx` below. Requires react and Tailwind CSS v4.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `keys` | `string[]` |  | The keys in order, e.g. ["⌘", "K"] or ["Ctrl", "Shift", "P"]. |
| `listen` | `boolean` | `true` | Press the keycaps when the real keys are held. |
| `onTrigger` | `() => void` |  | Called when the whole combination is pressed. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Keycap size. |

## Accessibility

- **Keyboard: Pass.** Nothing to focus: it listens to the keyboard you're already using, and onTrigger fires when the whole combination is held.
- **Screen readers: Pass.** Read as one kbd element with spoken key names, such as "Command K", rather than symbols.
- **Reduced motion: Pass.** The press is a 75ms, 2px shift that mirrors your own key press, so it isn't motion you didn't cause.
- **Colour contrast: Pass.** Key labels measure 11.8:1 on the keycap.

## Source

```tsx
"use client";

import { useEffect, useRef, useState } from "react";

type KeyComboProps = {
  /** The keys, in order, e.g. ["⌘", "K"] or ["Ctrl", "Shift", "P"]. */
  keys: string[];
  /** Press the keycaps down when the real keys are held. */
  listen?: boolean;
  /** Called when the whole combination is pressed. */
  onTrigger?: () => void;
  /** Keycap size. */
  size?: "sm" | "md" | "lg";
  className?: string;
};

// what each label means on a keyboard event, and how screen readers should say it
const keyMap: Record<string, { match: (event: KeyboardEvent) => boolean; spoken: string }> = {
  "⌘": { match: (e) => e.metaKey, spoken: "Command" },
  Cmd: { match: (e) => e.metaKey, spoken: "Command" },
  Ctrl: { match: (e) => e.ctrlKey, spoken: "Control" },
  "⌃": { match: (e) => e.ctrlKey, spoken: "Control" },
  Shift: { match: (e) => e.shiftKey, spoken: "Shift" },
  "⇧": { match: (e) => e.shiftKey, spoken: "Shift" },
  Alt: { match: (e) => e.altKey, spoken: "Alt" },
  "⌥": { match: (e) => e.altKey, spoken: "Option" },
};
const named: Record<string, string> = { Enter: "Enter", "↵": "Enter", Esc: "Escape", "⌫": "Backspace", Tab: "Tab", Space: " ", "↑": "ArrowUp", "↓": "ArrowDown", "←": "ArrowLeft", "→": "ArrowRight" };

const sizes = { sm: "min-w-6 h-6 px-1.5 text-[11px]", md: "min-w-8 h-8 px-2 text-xs", lg: "min-w-11 h-11 px-3 text-sm" };

/** Keycaps for a keyboard shortcut. With `listen`, each cap presses down while you hold its real key. */
export function KeyCombo({ keys, listen = true, onTrigger, size = "md", className = "" }: KeyComboProps) {
  const [held, setHeld] = useState<boolean[]>(() => keys.map(() => false));
  const fired = useRef(false);

  useEffect(() => {
    if (!listen) return;
    const isHeld = (label: string, event: KeyboardEvent) =>
      keyMap[label]?.match(event) ?? (named[label] ? event.key === named[label] : event.key.toLowerCase() === label.toLowerCase());
    function update(event: KeyboardEvent) {
      // a key that's being released no longer counts, even though the event still names it
      const next = keys.map((label) => (event.type === "keyup" && !keyMap[label] && isHeld(label, event) ? false : isHeld(label, event)));
      setHeld(next);
      if (next.every(Boolean) && !fired.current) {
        fired.current = true;
        onTrigger?.();
      }
      if (!next.every(Boolean)) fired.current = false;
    }
    const clear = () => setHeld(keys.map(() => false));
    window.addEventListener("keydown", update);
    window.addEventListener("keyup", update);
    window.addEventListener("blur", clear);
    return () => {
      window.removeEventListener("keydown", update);
      window.removeEventListener("keyup", update);
      window.removeEventListener("blur", clear);
    };
  }, [keys, listen, onTrigger]);

  const spoken = keys.map((label) => keyMap[label]?.spoken ?? named[label] ?? label).join(" ");

  return (
    <kbd aria-label={spoken} className={`inline-flex items-center gap-1 font-sans ${className}`}>
      {keys.map((label, index) => (
        <kbd
          key={`${label}-${index}`}
          aria-hidden
          className={`inline-flex items-center justify-center rounded-md border border-white/15 bg-[#1D2727] font-medium text-[#DCE3E2] transition-all duration-75 ${sizes[size]} ${
            held[index] ? "translate-y-[2px] border-b border-[#5FAEB4]/60 text-[#F6F1E8] shadow-none" : "border-b-[3px] shadow-[0_2px_0_rgb(0_0_0/0.4)]"
          }`}
        >
          {label}
        </kbd>
      ))}
    </kbd>
  );
}
```
