Inputs

Shortcut recorder

Records a keyboard shortcut the user presses, for letting people rebind keys on a settings screen.

Built on
<button><kbd>KeyboardEvent.code<input type="hidden">aria-live
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
5.4 kB
Dependencies
React
Code
import { ShortcutRecorder } from "@/components/equip/shortcut-recorder";

export default function Demo() {
  return <ShortcutRecorder label="Open command menu" defaultValue="Mod+K" name="command-menu" />;
}

Examples

Empty, with a hint

A combo needs at least one modifier, or a function key on its own. Escape cancels and Backspace clears.

Click, then press the keys you want to use.

Code
import { ShortcutRecorder } from "@/components/equip/shortcut-recorder";

export default function Example() {
  return (
    <div style={{ width: 240 }}>
      <ShortcutRecorder label="Toggle dark mode" hint="Click, then press the keys you want to use." name="toggle-theme" />
    </div>
  );
}

Disabled

Set by your organisation.

Code
import { ShortcutRecorder } from "@/components/equip/shortcut-recorder";

export default function Example() {
  return (
    <ShortcutRecorder
      label="Quit application"
      defaultValue="Mod+Q"
      hint="Set by your organisation."
      disabled
    />
  );
}

In a settings list

Values are stored with Mod for the platform's main modifier, so Mod+Shift+P means Command on a Mac and Ctrl elsewhere.

Saved as Mod+Shift+P and Mod+Alt+F

Code
"use client";

import { useState } from "react";
import { ShortcutRecorder } from "@/components/equip/shortcut-recorder";

export default function Example() {
  const [palette, setPalette] = useState("Mod+Shift+P");
  const [search, setSearch] = useState("Mod+Alt+F");
  return (
    <div style={{ display: "grid", gap: 16, maxWidth: 260 }}>
      <ShortcutRecorder label="Command palette" value={palette} onValueChange={setPalette} />
      <ShortcutRecorder label="Search in files" value={search} onValueChange={setSearch} />
      <p style={{ margin: 0, fontSize: 13, color: "var(--eq-ink-2)" }}>
        Saved as <code>{palette || "none"}</code> and <code>{search || "none"}</code>
      </p>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneVisible label. Also starts the control's accessible name.
hintReactNodenoneHelp text under the control, linked with aria-describedby.
valuestringnoneControlled value, such as Mod+Shift+K. An empty string is no shortcut.
defaultValuestring""Uncontrolled starting value.
onValueChange(value: string) => voidnoneCalled when a shortcut is recorded or cleared.
namestringnoneRenders a hidden input with this name, so the value is sent with a form.
...restComponentProps<"button">noneEvery native button attribute, including disabled, id and ref, on the recording control. className and style go to the wrapper.

Anatomy

root.eq-shortcut-recorderWrapper that carries className and style.
label.eq-shortcut-recorder-label
field.eq-shortcut-recorder-fieldThe bordered box holding the control and the clear button.
control.eq-shortcut-recorder-controlThe button that records; data-recording and data-eq-capturing while it listens.
keys.eq-shortcut-recorder-keys
key.eq-shortcut-recorder-keyOne key cap.
placeholder.eq-shortcut-recorder-placeholderNot set, or Press keys while recording.
clear.eq-shortcut-recorder-clearShown when a value is set; out of the tab order.
hint.eq-shortcut-recorder-hint
status.eq-shortcut-recorder-statusVisually hidden polite live region.

Keyboard

EnterSpaceStarts recording.
Modifier+KeyWhile recording, sets the shortcut once a key is pressed with Ctrl, Alt, Shift or Meta.
F1While recording, a function key alone is also accepted.
EscapeCancels recording and keeps the old value.
BackspaceDeleteClears the shortcut, while recording or not.
TabMoves focus on as usual; leaving cancels recording.

Notes

  • Values are stored as text with Mod for the platform's main modifier, Command on Apple and Ctrl elsewhere, so a saved shortcut travels between machines.
  • Ctrl, Alt, Shift and Meta in a value are literal, and the order is always Mod, Ctrl, Alt, Shift, Meta, then the key.
  • Letters and digits are read from the physical key, so Option or Shift on a Mac does not turn K into a symbol.
  • Keys are shown with Apple symbols on Apple platforms and as Ctrl, Alt, Shift and Win elsewhere; the first render uses the non-Apple names and upgrades after mount.
  • While recording, key presses are stopped from reaching page shortcuts, and only then.
  • Some combinations, such as Command+W or Command+Q, are kept by the browser or the system and cannot be recorded.
  • The control's accessible name joins the label and the current shortcut, and changes are announced in a polite live region.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/shortcut-recorder.tsx395 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, FocusEvent, KeyboardEvent, MouseEvent, ReactNode, Ref } from "react";
import "./shortcut-recorder.css";

export type ShortcutRecorderProps = Omit<
  ComponentProps<"button">,
  "value" | "defaultValue" | "name" | "type" | "children"
> & {
  /** Visible label. Also starts the control's accessible name. */
  label: string;
  /** Help text under the control, linked with aria-describedby. */
  hint?: ReactNode;
  /**
   * Controlled value, such as "Mod+Shift+K". Mod is Command on Apple platforms and Ctrl
   * elsewhere; Ctrl, Alt, Shift and Meta are literal. An empty string is no shortcut.
   */
  value?: string;
  /** Uncontrolled starting value. */
  defaultValue?: string;
  /** Called with the new value when a shortcut is recorded or cleared. */
  onValueChange?: (value: string) => void;
  /** Renders a hidden input with this name, so the value is sent with a form. */
  name?: string;
};

type Modifier = "Ctrl" | "Alt" | "Shift" | "Meta";

const ORDER: Modifier[] = ["Ctrl", "Alt", "Shift", "Meta"];

const ALIASES: Record<string, Modifier | "Mod"> = {
  mod: "Mod",
  ctrl: "Ctrl",
  control: "Ctrl",
  alt: "Alt",
  option: "Alt",
  opt: "Alt",
  shift: "Shift",
  meta: "Meta",
  cmd: "Meta",
  command: "Meta",
  win: "Meta",
  super: "Meta",
};

const NAMED_CODES: Record<string, string> = {
  Space: "Space",
  Enter: "Enter",
  NumpadEnter: "Enter",
  Tab: "Tab",
  Backspace: "Backspace",
  Delete: "Delete",
  Insert: "Insert",
  Home: "Home",
  End: "End",
  PageUp: "PageUp",
  PageDown: "PageDown",
  ArrowUp: "Up",
  ArrowDown: "Down",
  ArrowLeft: "Left",
  ArrowRight: "Right",
  Minus: "-",
  Equal: "=",
  BracketLeft: "[",
  BracketRight: "]",
  Backslash: "\\",
  Semicolon: ";",
  Quote: "'",
  Comma: ",",
  Period: ".",
  Slash: "/",
  Backquote: "`",
};

const LABELS: Record<"apple" | "other", Record<Modifier, { cap: string; spoken: string }>> = {
  apple: {
    Ctrl: { cap: "⌃", spoken: "Control" },
    Alt: { cap: "⌥", spoken: "Option" },
    Shift: { cap: "⇧", spoken: "Shift" },
    Meta: { cap: "⌘", spoken: "Command" },
  },
  other: {
    Ctrl: { cap: "Ctrl", spoken: "Ctrl" },
    Alt: { cap: "Alt", spoken: "Alt" },
    Shift: { cap: "Shift", spoken: "Shift" },
    Meta: { cap: "Win", spoken: "Win" },
  },
};

const FUNCTION_KEY = /^F([1-9]|1[0-9]|2[0-4])$/;

function setRefs<T>(node: T, ...refs: (Ref<T> | undefined)[]) {
  for (const ref of refs) {
    if (typeof ref === "function") ref(node);
    else if (ref) ref.current = node;
  }
}

/** Splits a stored value into concrete modifiers, in display order, and the key. */
function parse(value: string, apple: boolean): { mods: Modifier[]; key: string } | null {
  if (!value) return null;
  const pieces = value.split("+").filter(Boolean);
  const key = pieces.pop();
  if (!key) return null;
  const held = new Set<Modifier>();
  for (const piece of pieces) {
    const alias = ALIASES[piece.toLowerCase()];
    if (alias === "Mod") held.add(apple ? "Meta" : "Ctrl");
    else if (alias) held.add(alias);
  }
  return { mods: ORDER.filter((m) => held.has(m)), key };
}

/** Builds the stored value: the platform's main modifier is written as Mod, so it travels. */
function serialise(mods: Set<Modifier>, key: string, apple: boolean): string {
  const primary: Modifier = apple ? "Meta" : "Ctrl";
  const parts: string[] = [];
  if (mods.has(primary)) parts.push("Mod");
  for (const m of ORDER) if (m !== primary && mods.has(m)) parts.push(m);
  parts.push(key);
  return parts.join("+");
}

/** The name of the non-modifier key, read from the physical key where layout tricks would mislead. */
function keyName(event: KeyboardEvent): string | null {
  const { code, key } = event;
  if (/^Key[A-Z]$/.test(code)) return code.slice(3);
  if (/^Digit[0-9]$/.test(code)) return code.slice(5);
  if (/^Numpad[0-9]$/.test(code)) return `Num${code.slice(6)}`;
  if (FUNCTION_KEY.test(key)) return key;
  if (code in NAMED_CODES) return NAMED_CODES[code];
  if (key.length === 1 && key !== "+") return key.toUpperCase();
  return null;
}

function heldModifiers(event: KeyboardEvent): Set<Modifier> {
  const held = new Set<Modifier>();
  if (event.ctrlKey) held.add("Ctrl");
  if (event.altKey) held.add("Alt");
  if (event.shiftKey) held.add("Shift");
  if (event.metaKey) held.add("Meta");
  return held;
}

const MODIFIER_KEYS = new Set(["Control", "Alt", "AltGraph", "Shift", "Meta", "OS", "Hyper", "Super"]);
const IGNORED_KEYS = new Set(["Dead", "Process", "Unidentified", "CapsLock", "NumLock", "ScrollLock", "Fn", "FnLock"]);

/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
  return node != null && node !== false && node !== "";
}

export function ShortcutRecorder({
  label,
  hint,
  value,
  defaultValue = "",
  onValueChange,
  name,
  disabled,
  className,
  style,
  id,
  ref,
  onClick,
  onKeyDown,
  onKeyUp,
  onBlur,
  "aria-describedby": describedBy,
  ...rest
}: ShortcutRecorderProps) {
  const autoId = useId();
  const controlId = id ?? autoId;
  const hintId = `${autoId}-hint`;
  const control = useRef<HTMLButtonElement | null>(null);
  const [inner, setInner] = useState(defaultValue);
  const [recording, setRecording] = useState(false);
  const [held, setHeld] = useState<Modifier[]>([]);
  const [status, setStatus] = useState("");
  // Server and first client render use the non-Apple names; the effect upgrades them.
  const [apple, setApple] = useState(false);
  /**
   * Set by every key press taken while recording. Once recording ends it swallows that
   * press's auto-repeats and a Space release, which would otherwise click or reach the page.
   * The next fresh key press or a blur clears it.
   */
  const justRecorded = useRef(false);
  const current = value !== undefined ? value : inner;

  useEffect(() => {
    const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
    const platform = nav.userAgentData?.platform || nav.platform || nav.userAgent;
    setApple(/mac|iphone|ipad|ipod/i.test(platform));
  }, []);

  const names = LABELS[apple ? "apple" : "other"];
  const parsed = parse(current, apple);
  const spoken = parsed ? [...parsed.mods.map((m) => names[m].spoken), parsed.key].join(" ") : "";

  function set(next: string, message: string) {
    setRecording(false);
    setHeld([]);
    setStatus(message);
    if (next === current) return;
    if (value === undefined) setInner(next);
    onValueChange?.(next);
  }

  function startRecording() {
    setRecording(true);
    setHeld([]);
    setStatus("Recording");
    // Safari does not focus a button on click; keys and blur need focus here.
    control.current?.focus();
  }

  function stopRecording(message = "") {
    setRecording(false);
    setHeld([]);
    if (message) setStatus(message);
  }

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    onClick?.(event);
    if (event.defaultPrevented || disabled) return;
    if (recording) stopRecording("Cancelled");
    else startRecording();
  }

  function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented || disabled) return;

    if (!recording) {
      if (justRecorded.current) {
        // A combo held past the auto-repeat delay keeps firing after it was recorded.
        if (event.repeat) {
          event.preventDefault();
          event.stopPropagation();
          return;
        }
        justRecorded.current = false;
      }
      if ((event.key === "Backspace" || event.key === "Delete") && current) {
        event.preventDefault();
        set("", "Cleared");
      }
      return;
    }

    const mods = heldModifiers(event);
    // Plain Tab and Shift+Tab move focus as usual; leaving cancels the recording.
    if (event.key === "Tab" && !event.ctrlKey && !event.altKey && !event.metaKey) return;

    event.preventDefault();
    event.stopPropagation();
    justRecorded.current = true;
    if (event.nativeEvent.isComposing || IGNORED_KEYS.has(event.key)) return;

    if (MODIFIER_KEYS.has(event.key)) {
      setHeld(ORDER.filter((m) => mods.has(m)));
      return;
    }

    if (mods.size === 0) {
      if (event.key === "Escape") stopRecording("Cancelled");
      else if (event.key === "Backspace" || event.key === "Delete") set("", "Cleared");
      else if (FUNCTION_KEY.test(event.key)) commit(mods, event.key);
      // Any other key alone is not a shortcut; keep listening.
      return;
    }

    const key = keyName(event);
    if (key) commit(mods, key);
  }

  function commit(mods: Set<Modifier>, key: string) {
    const next = serialise(mods, key, apple);
    const words = [...ORDER.filter((m) => mods.has(m)).map((m) => names[m].spoken), key].join(" ");
    set(next, `Set to ${words}`);
  }

  function handleKeyUp(event: KeyboardEvent<HTMLButtonElement>) {
    onKeyUp?.(event);
    if (recording) {
      event.preventDefault();
      event.stopPropagation();
      setHeld(ORDER.filter((m) => heldModifiers(event).has(m)));
      return;
    }
    if (event.key === " " && justRecorded.current) {
      // The Space that finished a combo is released here; it must not start a new recording.
      event.preventDefault();
      event.stopPropagation();
    }
  }

  function handleBlur(event: FocusEvent<HTMLButtonElement>) {
    onBlur?.(event);
    justRecorded.current = false;
    if (recording) stopRecording();
  }

  function keepFocus(event: MouseEvent<HTMLButtonElement>) {
    event.preventDefault();
  }

  function handleClear() {
    set("", "Cleared");
    control.current?.focus();
  }

  const accessibleName = recording
    ? `${label}, recording, press a key combination`
    : `${label}, ${parsed ? spoken : "not set"}`;

  const shown = recording ? (held.length > 0 ? { mods: held, key: "" } : null) : parsed;

  return (
    <div
      className={["eq-shortcut-recorder", className].filter(Boolean).join(" ")}
      style={style}
      data-recording={recording || undefined}
    >
      <label className="eq-shortcut-recorder-label" htmlFor={controlId}>
        {label}
      </label>
      <div className="eq-shortcut-recorder-field" data-disabled={disabled || undefined}>
        <button
          {...rest}
          ref={(node) => setRefs(node, control, ref)}
          id={controlId}
          type="button"
          className="eq-shortcut-recorder-control"
          data-recording={recording || undefined}
          data-eq-capturing={recording ? "" : undefined}
          aria-label={accessibleName}
          aria-describedby={[describedBy, present(hint) ? hintId : null].filter(Boolean).join(" ") || undefined}
          disabled={disabled}
          onClick={handleClick}
          onKeyDown={handleKeyDown}
          onKeyUp={handleKeyUp}
          onBlur={handleBlur}
        >
          {shown ? (
            <span className="eq-shortcut-recorder-keys" aria-hidden="true">
              {shown.mods.map((m) => (
                <kbd key={m} className="eq-shortcut-recorder-key">
                  {names[m].cap}
                </kbd>
              ))}
              {shown.key && <kbd className="eq-shortcut-recorder-key">{shown.key}</kbd>}
            </span>
          ) : (
            <span className="eq-shortcut-recorder-placeholder" aria-hidden="true">
              {recording ? "Press keys…" : "Not set"}
            </span>
          )}
        </button>
        {current && !recording && (
          <button
            type="button"
            className="eq-shortcut-recorder-clear"
            tabIndex={-1}
            aria-label={`Clear ${label}`}
            disabled={disabled}
            onMouseDown={keepFocus}
            onClick={handleClear}
          >
            <svg
              viewBox="0 0 16 16"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              aria-hidden="true"
            >
              <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />
            </svg>
          </button>
        )}
      </div>
      {present(hint) && (
        <p className="eq-shortcut-recorder-hint" id={hintId}>
          {hint}
        </p>
      )}
      {name && <input type="hidden" name={name} value={current} disabled={disabled} />}
      <span className="eq-shortcut-recorder-status" aria-live="polite">
        {status}
      </span>
    </div>
  );
}
components/equip/shortcut-recorder.css198 lines
@layer equip {
  .eq-shortcut-recorder {
    display: grid;
    justify-items: start;
    gap: var(--eq-space-1);
    min-width: 0;
  }

  .eq-shortcut-recorder-label {
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  .eq-shortcut-recorder-hint {
    margin: 0;
    color: var(--eq-ink-2);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  /* The bordered box is what the eye reads as the control; the button inside is bare. */
  .eq-shortcut-recorder-field {
    box-sizing: border-box;
    display: flex;
    align-items: center;
    width: calc(var(--eq-h-m) * 5);
    max-width: 100%;
    height: var(--eq-h-m);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    color: var(--eq-ink);
    transition:
      border-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-shortcut-recorder-field:has(.eq-shortcut-recorder-control:active:not(:disabled)) {
    scale: var(--eq-press);
  }

  @media (hover: hover) {
    .eq-shortcut-recorder-field:hover:where(:not([data-disabled])) {
      border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
    }
  }

  /* Recording keeps the ring on even after a mouse click, so it is clear keys are being taken. */
  .eq-shortcut-recorder-field:has(.eq-shortcut-recorder-control:focus-visible),
  .eq-shortcut-recorder-field:has(.eq-shortcut-recorder-control[data-recording]) {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-shortcut-recorder-control {
    display: flex;
    flex: 1 1 auto;
    align-items: center;
    align-self: stretch;
    min-width: 0;
    margin: 0;
    padding: 0 var(--eq-space-3);
    border: 0;
    border-radius: calc(var(--eq-radius-m) - 1px);
    background: transparent;
    color: inherit;
    font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
    text-align: start;
    cursor: pointer;
    transition: background-color var(--eq-dur) var(--eq-ease);
  }

  .eq-shortcut-recorder-control:focus-visible {
    outline: none;
  }

  .eq-shortcut-recorder-control[data-recording] {
    background: var(--eq-tint);
  }

  .eq-shortcut-recorder-keys {
    display: inline-flex;
    align-items: center;
    gap: var(--eq-space-1);
    min-width: 0;
    overflow: hidden;
  }

  .eq-shortcut-recorder-key {
    box-sizing: border-box;
    display: inline-flex;
    flex: none;
    align-items: center;
    justify-content: center;
    min-width: 22px;
    height: 22px;
    padding-inline: var(--eq-space-1);
    border: 1px solid var(--eq-line-strong);
    border-bottom-width: 2px;
    border-radius: var(--eq-radius-s);
    background: var(--eq-raised);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1 var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
    white-space: nowrap;
    /* Caps pop in one after another as a combination lands. */
    transition:
      opacity var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-shortcut-recorder-key:nth-child(2) {
    transition-delay: calc(var(--eq-dur) / 3);
  }

  .eq-shortcut-recorder-key:nth-child(3) {
    transition-delay: calc(var(--eq-dur) * 2 / 3);
  }

  .eq-shortcut-recorder-key:nth-child(n + 4) {
    transition-delay: var(--eq-dur);
  }

  /* Only while the field has focus, so a saved shortcut is simply there on page load. */
  @starting-style {
    .eq-shortcut-recorder-field:focus-within .eq-shortcut-recorder-key {
      opacity: 0;
      scale: 0.6;
    }
  }

  .eq-shortcut-recorder-placeholder {
    overflow: hidden;
    color: var(--eq-ink-3);
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .eq-shortcut-recorder-control[data-recording] .eq-shortcut-recorder-placeholder {
    color: var(--eq-ink-2);
  }

  .eq-shortcut-recorder-clear {
    display: grid;
    flex: none;
    place-items: center;
    width: 24px;
    height: 24px;
    margin-inline-end: var(--eq-space-1);
    padding: 0;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: var(--eq-ink-2);
    cursor: pointer;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  .eq-shortcut-recorder-clear > svg {
    width: 14px;
    height: 14px;
  }

  @media (hover: hover) {
    .eq-shortcut-recorder-clear:hover:not(:disabled) {
      background: var(--eq-tint);
      color: var(--eq-ink);
    }
  }

  .eq-shortcut-recorder-clear:active:not(:disabled) {
    background: var(--eq-tint-strong);
    scale: var(--eq-press);
  }

  .eq-shortcut-recorder-field[data-disabled] {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-shortcut-recorder-field[data-disabled] > * {
    cursor: not-allowed;
  }

  .eq-shortcut-recorder-status {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index