Shortcut Recorder
Press the keys you want: records a chord, platform-aware, and says when it clashes.
Control K. Press to change.
Installation
pnpm dlx @dowel-ui/cli add shortcut-recorderNo 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
| Prop | Type | Default |
|---|---|---|
label (required)What the shortcut triggers: "Open search". Names the control. | string | — |
platformMac shows ⌘⇧K; everything else shows Ctrl+Shift+K. Detected if omitted. | Platform | — |
requireModifierPrintable keys need a modifier. On by default, for the reason in the source. | boolean | true |
takenShortcuts other commands already use. A clash is said, and applied only on request. | TakenShortcut[] | [] |
valueStored form, | string | null | — |
Plus every attribute of <div> except onChange, defaultValue.
Quality
9/9 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — does not apply
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
Source
This is exactly what dowel add shortcut-recorder writes into your project, with imports rewritten to your own path alias.
/**
* 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";
}
"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;
}
}