Shortcut Recorder

Press the keys you want: records a chord, platform-aware, and says when it clashes.

Open search

Control K. Press to change.

Installation

Terminal
pnpm dlx @dowel-ui/cli add shortcut-recorder

No npm packages are needed beyond what Dowel already requires.

Accessibility

The recorder is a button named by what the shortcut does and described by its value in words — Command Shift K — while the key caps on it are hidden from assistive technology, since ⌘⇧K read aloud is noise. While recording it is aria-pressed and its description says what to do; Tab always leaves and Escape always cancels, so it is never a keyboard trap. Every outcome is announced through a status region present from the start: recorded, refused with the reason, clashing with which command, cancelled, cleared. Symbols are shown on a Mac and words elsewhere, and the description uses the platform's own names for its keys.

Props

ShortcutRecorder

PropTypeDefault
label (required)

What the shortcut triggers: "Open search". Names the control.

string
platform

Mac shows ⌘⇧K; everything else shows Ctrl+Shift+K. Detected if omitted.

Platform
requireModifier

Printable keys need a modifier. On by default, for the reason in the source.

booleantrue
taken

Shortcuts other commands already use. A clash is said, and applied only on request.

TakenShortcut[][]
value

Stored form, Mod+Shift+K, or null for none. Controlled.

string | null

Plus every attribute of <div> except onChange, defaultValue.

Quality

9/9 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokensdoes not apply
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add shortcut-recorder writes into your project, with imports rewritten to your own path alias.

ui/shortcut-model.ts
/**
 * A keyboard shortcut as a value: parsed from the string an app stores,
 * built from a keydown, and written back out for a Mac, for everyone else,
 * and for the ear.
 *
 * Pure, so the modifier canon, the platform rendering and the conflict rule
 * are tested without a keyboard, and so the same `parseShortcut` can run on
 * the server that validates a saved binding.
 *
 * The stored form is platform-neutral: `Mod+Shift+K`, where `Mod` is Command
 * on a Mac and Control elsewhere. That is the one decision every app makes
 * and few make explicitly — a binding saved as `Ctrl+K` on Windows is wrong
 * the moment the same account opens on a Mac.
 */

export type Platform = "mac" | "other";

export interface Shortcut {
  /** The non-modifier key, canonical: `K`, `1`, `ArrowUp`, `F5`, `Enter`, `Space`. */
  key: string;
  /** Command on a Mac, Control elsewhere. */
  mod: boolean;
  /** Control, held explicitly — on a Mac this is a different key from Mod. */
  ctrl: boolean;
  alt: boolean;
  shift: boolean;
}

const MODIFIER_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "AltGraph", "Fn"]);

/** Keys that cannot be a shortcut, because they already mean something. */
export const RESERVED_KEYS = new Set(["Tab", "Escape"]);

const KEY_ALIASES: Record<string, string> = {
  " ": "Space",
  spacebar: "Space",
  esc: "Escape",
  return: "Enter",
  del: "Delete",
  up: "ArrowUp",
  down: "ArrowDown",
  left: "ArrowLeft",
  right: "ArrowRight",
  cmd: "Mod",
  command: "Mod",
  meta: "Mod",
  control: "Ctrl",
  option: "Alt",
};

/** The canonical key name for a token someone typed or stored. */
function canonicalKey(token: string): string {
  const alias = KEY_ALIASES[token.toLowerCase()];
  if (alias) return alias;
  if (token.length === 1) return token.toUpperCase();
  // ArrowUp, F5, Enter, Backspace, Delete, Home, End, PageUp…
  return token.charAt(0).toUpperCase() + token.slice(1);
}

/**
 * `Mod+Shift+K` to a shortcut. Accepts the spellings people use — `Cmd`,
 * `Command`, `Ctrl`, `Option` — and throws on an empty or modifier-only one.
 */
export function parseShortcut(text: string): Shortcut {
  const tokens = text
    .split("+")
    .map((token) => token.trim())
    .filter(Boolean);
  if (tokens.length === 0) throw new Error("Enter a shortcut.");

  const shortcut: Shortcut = { key: "", mod: false, ctrl: false, alt: false, shift: false };
  for (const token of tokens) {
    const canonical = canonicalKey(token);
    if (canonical === "Mod") shortcut.mod = true;
    else if (canonical === "Ctrl") shortcut.ctrl = true;
    else if (canonical === "Alt") shortcut.alt = true;
    else if (canonical === "Shift") shortcut.shift = true;
    else if (shortcut.key) throw new Error(`"${text}" has two keys; a shortcut has one.`);
    else shortcut.key = canonical;
  }
  if (!shortcut.key) throw new Error(`"${text}" is only modifiers; it needs a key.`);
  return shortcut;
}

/** The stored, platform-neutral form. Modifiers in a fixed order, so equal shortcuts are equal strings. */
export function serializeShortcut(shortcut: Shortcut): string {
  const parts: string[] = [];
  if (shortcut.mod) parts.push("Mod");
  if (shortcut.ctrl) parts.push("Ctrl");
  if (shortcut.alt) parts.push("Alt");
  if (shortcut.shift) parts.push("Shift");
  parts.push(shortcut.key);
  return parts.join("+");
}

export function shortcutsEqual(a: Shortcut, b: Shortcut): boolean {
  return serializeShortcut(a) === serializeShortcut(b);
}

interface KeyLike {
  key: string;
  code?: string;
  metaKey: boolean;
  ctrlKey: boolean;
  altKey: boolean;
  shiftKey: boolean;
}

/**
 * The shortcut a keydown amounts to, or null while only modifiers are held.
 *
 * Letters and digits come from `code`, not `key`: with Shift or Option held,
 * `key` is `K`, `˚` or `∆` depending on the layout and the platform, and a
 * shortcut recorded as Option-∆ is one nobody can read back. `Mod` is Meta
 * on a Mac and Control elsewhere; the other one, when held, is kept as
 * itself.
 */
export function shortcutFromKey(event: KeyLike, platform: Platform): Shortcut | null {
  if (MODIFIER_KEYS.has(event.key)) return null;

  let key: string;
  const code = event.code ?? "";
  if (/^Key[A-Z]$/.test(code)) key = code.slice(3);
  else if (/^Digit[0-9]$/.test(code)) key = code.slice(5);
  else key = canonicalKey(event.key);

  const mac = platform === "mac";
  return {
    key,
    mod: mac ? event.metaKey : event.ctrlKey,
    ctrl: mac ? event.ctrlKey : false,
    alt: event.altKey,
    shift: event.shiftKey,
  };
}

/** Printable keys need a modifier: a bare K as a shortcut fires while someone types a sentence. */
export function needsModifier(shortcut: Shortcut): boolean {
  const printable = shortcut.key.length === 1 || shortcut.key === "Space";
  return printable && !shortcut.mod && !shortcut.ctrl && !shortcut.alt;
}

const MAC_SYMBOLS: Record<string, string> = {
  Mod: "⌘",
  Ctrl: "⌃",
  Alt: "⌥",
  Shift: "⇧",
  Enter: "↩",
  Backspace: "⌫",
  Delete: "⌦",
  ArrowUp: "↑",
  ArrowDown: "↓",
  ArrowLeft: "←",
  ArrowRight: "→",
  Escape: "⎋",
  Space: "Space",
};

const SPOKEN: Record<Platform, Record<string, string>> = {
  mac: { Mod: "Command", Ctrl: "Control", Alt: "Option", Shift: "Shift" },
  other: { Mod: "Control", Ctrl: "Control", Alt: "Alt", Shift: "Shift" },
};

/** The parts to draw, in order: symbols on a Mac, words elsewhere. */
export function shortcutParts(shortcut: Shortcut, platform: Platform): string[] {
  const parts: string[] = [];
  const name = (modifier: string) =>
    platform === "mac"
      ? (MAC_SYMBOLS[modifier] ?? modifier)
      : (SPOKEN.other[modifier] ?? modifier);
  if (shortcut.mod) parts.push(name("Mod"));
  if (shortcut.ctrl && !(platform === "other" && shortcut.mod)) parts.push(name("Ctrl"));
  if (shortcut.alt) parts.push(name("Alt"));
  if (shortcut.shift) parts.push(name("Shift"));
  parts.push(platform === "mac" ? (MAC_SYMBOLS[shortcut.key] ?? shortcut.key) : shortcut.key);
  return parts;
}

/** "⌘⇧K" on a Mac, "Ctrl+Shift+K" elsewhere. */
export function formatShortcut(shortcut: Shortcut, platform: Platform): string {
  return shortcutParts(shortcut, platform).join(platform === "mac" ? "" : "+");
}

/** "Command Shift K" — words, for the ear, whatever the screen shows. */
export function describeShortcut(shortcut: Shortcut, platform: Platform): string {
  const words: string[] = [];
  const spoken = SPOKEN[platform];
  if (shortcut.mod) words.push(spoken.Mod ?? "Mod");
  if (shortcut.ctrl && !(platform === "other" && shortcut.mod))
    words.push(spoken.Ctrl ?? "Ctrl");
  if (shortcut.alt) words.push(spoken.Alt ?? "Alt");
  if (shortcut.shift) words.push(spoken.Shift ?? "Shift");
  words.push(shortcut.key.replace(/^Arrow/, "Arrow "));
  return words.join(" ");
}

/** Whether the browser is on a Mac, by the only signal it gives. */
export function detectPlatform(): Platform {
  if (typeof navigator === "undefined") return "other";
  const hint =
    (navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform ??
    navigator.platform;
  return /mac|iphone|ipad|ipod/i.test(hint) ? "mac" : "other";
}
ui/shortcut-recorder.tsx
"use client";

import {
  useId,
  useState,
  useSyncExternalStore,
  type ComponentPropsWithRef,
  type KeyboardEvent,
} from "react";

import { disabledStyles, focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

import {
  describeShortcut,
  detectPlatform,
  needsModifier,
  parseShortcut,
  serializeShortcut,
  shortcutFromKey,
  shortcutParts,
  shortcutsEqual,
  type Platform,
  type Shortcut,
} from "./shortcut-model";

/**
 * Press the keys you want.
 *
 * Linear, Slack, VS Code, Figma and Superhuman each wrote one, and no
 * component library ships the recorder — only the `<kbd>` that displays the
 * result. The recorder is the hard part: a button that, when pressed, stops
 * being a button and starts being a keyboard listener, and has to come back.
 *
 * Three things it does that a listener bolted onto an input does not. It
 * reads letters from `code`, not `key`, so Option-K records as Option K and
 * not as the ˚ the Mac produced. It stores `Mod`, not Command or Control,
 * so a binding saved on one machine is right on the other. And it refuses a
 * bare printable key by saying so, because a shortcut that fires while
 * someone types a sentence is the bug every app that allowed it later fixed.
 *
 * Tab and Escape are never recorded: Tab leaves, Escape cancels, and a
 * recorder that captures both is a keyboard trap with a nice label. A
 * chord that another command already uses is said, with that command's
 * name, and applied only if the person says to use it anyway.
 */

export interface TakenShortcut {
  /** Stored form: `Mod+K`. */
  shortcut: string;
  /** What it does: "Search". */
  label: string;
}

export interface ShortcutRecorderProps extends Omit<
  ComponentPropsWithRef<"div">,
  "onChange" | "defaultValue"
> {
  /** What the shortcut triggers: "Open search". Names the control. */
  label: string;
  /** Stored form, `Mod+Shift+K`, or null for none. Controlled. */
  value?: string | null;
  defaultValue?: string | null;
  onChange?: (value: string | null) => void;
  /** Shortcuts other commands already use. A clash is said, and applied only on request. */
  taken?: TakenShortcut[];
  /** Mac shows ⌘⇧K; everything else shows Ctrl+Shift+K. Detected if omitted. */
  platform?: Platform;
  /** Printable keys need a modifier. On by default, for the reason in the source. */
  requireModifier?: boolean;
}

/* The platform, as an external store that never changes: a constant that the
   server cannot know, so the server snapshot is the neutral one and the real
   answer arrives after hydration without a mismatch. */
const subscribeToNothing = () => () => undefined;
const readPlatform = () => detectPlatform();
const readNeutralPlatform = (): Platform => "other";

export function ShortcutRecorder({
  className,
  label,
  value: valueProp,
  defaultValue = null,
  onChange,
  taken = [],
  platform: platformProp,
  requireModifier = true,
  ...props
}: ShortcutRecorderProps) {
  const id = useId();
  const detected = useSyncExternalStore(subscribeToNothing, readPlatform, readNeutralPlatform);
  const platform = platformProp ?? detected;

  const [uncontrolled, setUncontrolled] = useState<string | null>(defaultValue);
  const value = valueProp === undefined ? uncontrolled : valueProp;
  const current = value ? safeParse(value) : null;

  const [recording, setRecording] = useState(false);
  /** Modifiers held mid-chord, shown so the person sees the recorder listening. */
  const [held, setHeld] = useState<Shortcut | null>(null);
  const [conflict, setConflict] = useState<{ shortcut: Shortcut; with: TakenShortcut } | null>(
    null,
  );
  const [announcement, setAnnouncement] = useState("");

  const commit = (next: Shortcut | null) => {
    const serialized = next ? serializeShortcut(next) : null;
    setUncontrolled(serialized);
    onChange?.(serialized);
  };

  const stop = () => {
    setRecording(false);
    setHeld(null);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (!recording) return;
    // Tab leaves. A recorder that keeps it is a keyboard trap.
    if (event.key === "Tab") {
      stop();
      setAnnouncement("Cancelled.");
      return;
    }
    event.preventDefault();
    if (event.key === "Escape") {
      stop();
      setAnnouncement("Cancelled.");
      return;
    }
    if ((event.key === "Backspace" || event.key === "Delete") && !hasModifier(event)) {
      stop();
      commit(null);
      setAnnouncement("Cleared.");
      return;
    }

    const chord = shortcutFromKey(event.nativeEvent, platform);
    if (!chord) {
      // Only modifiers so far. Show them, keep listening.
      setHeld({
        key: "",
        mod: platform === "mac" ? event.metaKey : event.ctrlKey,
        ctrl: platform === "mac" ? event.ctrlKey : false,
        alt: event.altKey,
        shift: event.shiftKey,
      });
      return;
    }

    if (requireModifier && needsModifier(chord)) {
      setAnnouncement(
        `${describeShortcut(chord, platform)} needs a modifier — it would fire while typing. Try again.`,
      );
      setHeld(null);
      return;
    }

    const clash = taken.find((entry) =>
      shortcutsEqual(safeParse(entry.shortcut) ?? chord, chord),
    );
    stop();
    if (clash && !(current && shortcutsEqual(current, chord))) {
      setConflict({ shortcut: chord, with: clash });
      setAnnouncement(
        `${describeShortcut(chord, platform)} is already used by ${clash.label}.`,
      );
      return;
    }
    commit(chord);
    setAnnouncement(`Recorded ${describeShortcut(chord, platform)}.`);
  };

  const onKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (!recording || !held) return;
    setHeld({
      key: "",
      mod: platform === "mac" ? event.metaKey : event.ctrlKey,
      ctrl: platform === "mac" ? event.ctrlKey : false,
      alt: event.altKey,
      shift: event.shiftKey,
    });
  };

  const shown = recording ? held : current;
  const description = recording
    ? "Press the keys you want. Escape cancels, Backspace clears."
    : current
      ? `${describeShortcut(current, platform)}. Press to change.`
      : "Not set. Press to record.";

  return (
    <div
      data-slot="shortcut-recorder"
      data-state={recording ? "recording" : conflict ? "conflict" : current ? "set" : "empty"}
      className={cn("flex flex-col gap-1.5", className)}
      {...props}
    >
      <span id={`${id}-label`} className="text-xs font-medium">
        {label}
      </span>

      <div className="flex flex-wrap items-center gap-2">
        <button
          type="button"
          aria-labelledby={`${id}-label`}
          aria-describedby={`${id}-description`}
          aria-pressed={recording}
          className={cn(
            "flex h-9 min-w-32 items-center justify-center gap-1 rounded-md border px-3 font-mono text-sm",
            recording
              ? "border-primary bg-primary/5 text-muted-foreground"
              : "border-input bg-background hover:bg-accent",
            focusRing,
            disabledStyles,
          )}
          onClick={() => {
            if (recording) return;
            setConflict(null);
            setRecording(true);
            setAnnouncement("");
          }}
          onKeyDown={onKeyDown}
          onKeyUp={onKeyUp}
          onBlur={() => {
            if (recording) {
              stop();
              setAnnouncement("Cancelled.");
            }
          }}
        >
          {/* Symbols and key caps are for the eye; the description says the
              same thing in words. */}
          <span aria-hidden="true" className="flex items-center gap-1">
            {shown && (shown.key || shown.mod || shown.ctrl || shown.alt || shown.shift) ? (
              shortcutParts(shown, platform)
                .filter(Boolean)
                .map((part, index) => (
                  <kbd
                    key={index}
                    className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-xs"
                  >
                    {part}
                  </kbd>
                ))
            ) : recording ? (
              <span className="text-xs">Press keys…</span>
            ) : (
              <span className="text-xs text-muted-foreground">Not set</span>
            )}
          </span>
        </button>

        {current && !recording ? (
          <button
            type="button"
            aria-label={`Clear — ${label}`}
            className={cn(
              "rounded-md border border-input bg-background px-2.5 py-1 text-xs font-medium hover:bg-accent",
              focusRing,
            )}
            onClick={() => {
              commit(null);
              setConflict(null);
              setAnnouncement("Cleared.");
            }}
          >
            Clear
          </button>
        ) : null}
      </div>

      <p id={`${id}-description`} className="text-xs text-muted-foreground">
        {description}
      </p>

      {conflict ? (
        <div
          data-slot="shortcut-conflict"
          className="flex flex-wrap items-center gap-2 rounded-md border border-warning/50 bg-warning/5 px-2.5 py-1.5 text-xs"
        >
          <span className="flex-1 text-warning">
            {describeShortcut(conflict.shortcut, platform)} is already used by{" "}
            <strong>{conflict.with.label}</strong>.
          </span>
          <button
            type="button"
            className={cn(
              "rounded-md border border-input bg-background px-2 py-0.5 font-medium hover:bg-accent",
              focusRing,
            )}
            onClick={() => {
              commit(conflict.shortcut);
              setConflict(null);
              setAnnouncement(
                `Recorded ${describeShortcut(conflict.shortcut, platform)}. ${conflict.with.label} no longer has a shortcut.`,
              );
            }}
          >
            Use anyway
          </button>
          <button
            type="button"
            className={cn(
              "rounded-md border border-input bg-background px-2 py-0.5 font-medium hover:bg-accent",
              focusRing,
            )}
            onClick={() => {
              setConflict(null);
            }}
          >
            Keep the old one
          </button>
        </div>
      ) : null}

      {/* Present from the start, so the first outcome is heard. */}
      <span role="status" aria-live="polite" className="sr-only">
        {announcement}
      </span>
    </div>
  );
}

function hasModifier(event: KeyboardEvent): boolean {
  return event.metaKey || event.ctrlKey || event.altKey || event.shiftKey;
}

function safeParse(text: string): Shortcut | null {
  try {
    return parseShortcut(text);
  } catch {
    return null;
  }
}