Shortcut Recorder
A field that captures a keyboard shortcut by pressing it — swallows every key while recording, waits for a real key past the modifiers, and tells reserved combos apart from ones that would override an existing binding.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/shortcut-recorder.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ShortcutRecorder" component (lucide-react
for the icons; no Radix, no portal — it is an inline field, one self-contained
file a buyer can drop into a settings page).
Contract
- Exports: ShortcutRecorder (named + default), the helper matchesShortcutCombo,
and the types ShortcutCombo, ShortcutConflict, ShortcutModifier,
ShortcutPlatform, ShortcutRecorderProps.
- ShortcutCombo: { key: string; modifiers: ("meta"|"ctrl"|"alt"|"shift")[] }.
`key` is the canonical, layout-independent name: a lower-case letter ("k"), a
digit ("4"), a punctuation glyph ("/"), a function key ("F5") or a named key
("Space" / "Enter" / "ArrowUp"). `modifiers` is ALWAYS emitted in the canonical
order meta → ctrl → alt → shift, so two values are comparable by string.
- ShortcutConflict: { combo: ShortcutCombo; label: string } — the label is the
human name of the binding that already owns the combo, and it is quoted back
in the warning. A bare combo array would not be enough: the whole point of the
conflict path is being able to say *whose* shortcut is about to be taken.
- Props: value?: ShortcutCombo | null (controlled, null = unassigned);
defaultValue?: ShortcutCombo | null (uncontrolled); onChange?: (value:
ShortcutCombo | null) => void; forbidden?: ShortcutCombo[]; conflicts?:
ShortcutConflict[]; allowBare?: boolean (default false); clearable?: boolean
(default true); disabled?: boolean; placeholder?: string (default "Not set");
platform?: "auto" | "mac" | "other" (default "auto"); label?: string (name of
the action, prefixes the accessible name); className merged with cn(); the
root div forwards its ref and spreads the remaining props.
- Incoming values are normalised on the way in as well: duplicate/unknown
modifiers dropped, order forced, single characters lower-cased. Consumers can
hand-write { key: "K", modifiers: ["shift","meta","shift"] } and comparisons
still work.
- matchesShortcutCombo(event, combo) answers "is this KeyboardEvent that combo?"
using exactly the same normalisation. It exists because a recorded value is
useless if the app matches it back with different rules; it is a pure
predicate, not a binding — listener lifecycle, enable/disable and scoping
belong to a hotkey hook.
Behavior
- Idle → recording on click, Enter or Space (it is a real <button>, so all three
arrive as one click). Recording ends on a completed combo, on Escape, on blur,
or on a second mouse click.
- While recording, a capture-phase keydown/keyup listener on `window` calls
preventDefault() + stopImmediatePropagation() on EVERY key before anything
else runs. This is the feature, not a detail: without it, recording ⌘S opens
the browser's save dialog, recording Tab moves focus out of the field, and
recording ⌘B fires whatever the host app already bound to ⌘B. Window capture
is the earliest point in the dispatch and stopImmediatePropagation ends the
dispatch there, so app hotkeys, React's own root listener and the browser
default all miss it. A React onKeyDown handler would be far too late — it runs
on the root container, after any window-capture listener the app installed.
- Modifiers never complete a combo. Meta/Control/Alt/Shift only update a live
preview built from event.metaKey/ctrlKey/altKey/shiftKey — read the flags off
the event rather than tracking down/up pairs, because macOS stops delivering
keyup for other keys while ⌘ is held. The preview paints the held modifier
caps plus a dashed "…" cap meaning "still waiting for a key".
- Key identity comes from event.code first, event.key only as a fallback. On
macOS ⌥K reports event.key === "˚" and ⇧4 reports "$"; storing those produces
a combo that can never be matched again. KeyA-KeyZ → lower-case letter,
Digit0-9 → digit, F1-F12 → "F5", and a punctuation table maps Slash/Period/
Comma/Minus/Equal/Bracket*/Quote/Semicolon/Backquote/Backslash/Space. Lock
keys, AltGraph, the context-menu key and the IME artefacts "Dead" /
"Process" / "Unidentified" are ignored entirely (they would freeze a nonsense
value into the user's settings), and event.repeat is dropped so auto-repeat
cannot re-commit.
- Escape cancels unconditionally, modifiers included, so ⌘Escape and ⇧Escape
are not recordable either. That is the same trade every OS settings panel
makes: one guaranteed way out of a field that is eating the keyboard.
- Escape cancels: the session ends and the previous value is what stays on
screen (nothing was ever written mid-session, so cancel is just "exit"). This
means Escape itself is not recordable — say so in your docs.
- Validation, three distinct outcomes:
1. Bare key (no modifier) with allowBare=false → REFUSED, value untouched,
session stays open so the user can immediately try again. A bare letter
fires while someone is typing, which is why it is off by default. F-keys
get no special case — set allowBare if you want F5 on its own.
2. Combo listed in `forbidden` → REFUSED the same way, with a "reserved"
message. Use it for keys the browser eats before any handler runs
(⌘W / ⌘T / ⌘N / ⌘Q, Ctrl+W…). Be honest in your UI: the field can refuse to
*store* them, but it cannot stop the browser from acting on the ones the OS
owns.
3. Combo listed in `conflicts` → ACCEPTED and committed, plus a warning naming
the binding it takes over ("Saved ⌘⇧K — this overrides “Command palette”").
Actually removing the other binding is the consumer's job; the field only
reports it.
- Clearing: the × button hands focus to the always-mounted trigger BEFORE it
sets the value to null, because it unmounts itself the moment the value is
gone and focusing a detached node drops focus to <body>.
- disabled uses aria-disabled, never the native disabled attribute. Native
disabled makes the element unfocusable and un-hoverable (no tooltip, screen
readers skip past it); handlers just return early instead. Flipping disabled
on mid-session ends the session through a render-phase adjust-state
comparison, not a setState inside an effect.
- Platform detection goes through useSyncExternalStore with an "other" server
snapshot. Reading navigator during render emits ⌘ on the client and Ctrl on
the server and hydration explodes.
- Cleanup: the two window listeners plus a window blur listener (⌘-Tab away
while holding a modifier never delivers the keyup, so the session must end)
are all removed when recording stops or the component unmounts.
Rendering & styling
- Semantic tokens only: field bg-background + border, recording adds border-ring
+ ring-2 ring-ring + bg-muted/40, a rejection adds border-destructive;
keycaps bg-muted + border + border-b-2 + text-muted-foreground; the status
line is text-muted-foreground, text-destructive for a refusal and
font-medium text-foreground for an override warning. No hex, no rgb(), no
oklch(), no palette class names, and no chart tokens on text.
- Modifier caps are painted in the platform's own order — ⌃⌥⇧⌘ on macOS (Apple
HIG puts Command last), Win → Ctrl → Alt → Shift elsewhere — while the stored
array keeps the canonical order. Only presentation is platform dependent.
- Accessibility: the trigger's aria-label reads the combo as words, never
glyphs — "Toggle sidebar — Current shortcut: Shift Command K. Activate to
record a new shortcut." (⌘ ⇧ ⌥ are noise through a screen reader). While
recording it becomes "Recording a shortcut. Press a key combination, or press
Escape to cancel." A permanently mounted role="status" under the field carries
the same message twice — a visible aria-hidden copy with glyphs and an sr-only
copy with words. Mount it always: a live region that only appears when there
is something to say loses its first announcement.
- The recording pulse is decorative: motion-reduce:animate-none, and every
transition is motion-reduce:transition-none. Recording works identically with
motion off.
Customization levers
- Density and size: field height (h-10), keycap height (h-6), status line size —
or override the lot through className on the root.
- Chrome: clearable={false} for a required binding; drop the placeholder icon;
replace the dashed "…" cap with the word "Recording" if you prefer text.
- Policy: `forbidden` is your reserved list (browser keys, OS keys, an
in-house "never rebind" set); `conflicts` is whatever your settings page
already has bound; allowBare opens single keys for surfaces with no text
input (a media player, a game).
- Vocabulary: extend the key label table to add media keys, numpad or
app-specific glyphs — each entry is just { glyph, spoken }.
- Platform: force platform="mac" / "other" for documentation screenshots and
leave "auto" in the product; swap the mac display order if you prefer
⌘ first.
- Storage: the value is deliberately literal (⌘ is "meta", Ctrl is "ctrl") —
it records what was physically pressed. If you want one portable binding
across platforms, map meta↔ctrl when you save, or keep one value per platform.Concepts
- Press-to-record — the value is produced by performing the shortcut, not by picking strings out of two dropdowns. That only works if the field can guarantee the press has no other effect, which is why the capture-phase swallow is the load-bearing part of the component rather than a nicety.
- Swallowing the key —
preventDefaultalone stops the browser;stopImmediatePropagationfrom a window capture listener also stops the host app's own hotkeys and React's root listener. Without it, recording ⌘S opens the save dialog, Tab walks focus out of the field, and recording a combo the app already binds fires that action mid-recording. - Modifier-only is not a combo — ⌘ and ⇧ update a live preview and nothing else; the session completes only on a non-modifier key. Modifier state is read from
event.metaKey/ctrlKey/altKey/shiftKeyrather than from down/up bookkeeping, because macOS withholdskeyupfor other keys while ⌘ is held. - Physical key, not typed character —
event.codeis the identity. On macOS ⌥K reportsevent.key === "˚"and ⇧4 reports"$"; storing the typed character produces a shortcut that can never be matched again, so the code table wins andevent.keyis only the fallback for named keys. - Reserved vs already taken — two rejections that look the same to a naive implementation but must not behave the same. A
forbiddencombo is refused (nothing is written, the session stays open for another try); aconflictscombo is accepted and the field says whose binding it just took over. Collapsing them into one "invalid" state either blocks legitimate rebinding or silently steals a shortcut. aria-disabled, notdisabled— the native attribute makes the field unfocusable and hoverless, so a screen reader walks straight past a row the user is looking at and no tooltip can explain why it is locked. Handlers return early instead, and the accessible name drops its "activate to record" half so it never promises an action that will not happen.- Glyph vs spoken name — the caps paint ⌘ ⇧ ⌥, the accessible name says "Shift Command K". The status line ships both: an
aria-hiddenglyph copy and ansr-onlyword copy inside one permanently mountedrole="status", because a live region that mounts only when it has news loses its first announcement.
Gradient Picker
A CSS gradient editor — draggable stop rail, angle dial, per-stop colour editing, and a live read-only linear/radial/conic CSS string.
CSV Import
A three-step CSV importer — RFC 4180 parsing, delimiter and BOM detection, auto-guessed column mapping with duplicate and required gates, and a capped preview that reports ragged rows by line number.