Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertTriangle, Ban, Keyboard, X } from "lucide-react"
import { cn } from "@/lib/utils"

export type ShortcutModifier = "meta" | "ctrl" | "alt" | "shift"

export type ShortcutPlatform = "mac" | "other"

export interface ShortcutCombo {
  /**
   * Canonical, keyboard-layout-independent name of the non-modifier key: a
   * lower-case letter (`"k"`), a digit (`"4"`), a punctuation glyph (`"/"`), a

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/shortcut-recorder.json

Prompt

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 keypreventDefault alone stops the browser; stopImmediatePropagation from 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/shiftKey rather than from down/up bookkeeping, because macOS withholds keyup for other keys while ⌘ is held.
  • Physical key, not typed characterevent.code is the identity. On macOS ⌥K reports event.key === "˚" and ⇧4 reports "$"; storing the typed character produces a shortcut that can never be matched again, so the code table wins and event.key is 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 forbidden combo is refused (nothing is written, the session stays open for another try); a conflicts combo 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, not disabled — 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-hidden glyph copy and an sr-only word copy inside one permanently mounted role="status", because a live region that mounts only when it has news loses its first announcement.

On This Page