DEV Community

Louis Liu
Louis Liu

Posted on

Building a Custom Key Binding Recorder in React

I’ve been working on a desktop AI assistant — SnapMind — that lets you instantly interact with LLMs from anywhere on your system. One of the core features is triggering prompts with customizable hotkeys.

By default, the app comes with built-in hotkeys, but I wanted users to be able to define their own key bindings.

At first, I considered using a third-party library for this. But I soon realized it wasn’t too hard to build my own lightweight recorder, so I decided to implement it from scratch. In this post, I’ll share the key points of how I designed this component.


Rules

The component should allow users to provide up to 2 modifier keys and one main key. Once a valid key binding is captured, the recording should stop.

  • Start listening when the input is focused
  • The final key binding = up to 2 modifiers + 1 main key
  • Automatically commit once a valid combination is captured (≥1 modifier + 1 main key)
  • Provide a reset button to clear user inputs

States

Here’s how I structured the component’s state:

const [keybindings, setKeybindings] = useState<string | null>(null); // The final result
const [modKeys, setModKeys] = useState<Set<string>>(new Set()); // Active modifiers captured (max 2)
const [mainKey, setMainKey] = useState<string | null>(null); // Main (non-modifier) key
const [recording, setRecording] = useState(false); // Indicate recording state
const inputRef = useRef<HTMLInputElement | null>(null); // Input reference
Enter fullscreen mode Exit fullscreen mode

Listen to key down

Initially, I wanted the component to display key presses in real time — for example, showing Ctrl + Shift + C when held, then Ctrl + C once Shift is released. That would have been cool, but I realized a simpler approach was enough: just capture the combination on key down and persist it until reset.

const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (!recording || disabled || keybindings) return;
  e.preventDefault();
  const k = e.key;
  const nextMods = new Set(modKeys); // Use Set here so it won't record duplicated keys
  let nextMain = mainKey;
  if (isModifier(k)) {
    // Handle keys like Ctrl and Shift.
    const lower = k.toLowerCase();
    if (nextMods.has(lower) || nextMods.size < 2) { // We're using Set, so I don't care about capture duplicate modifiers
      nextMods.add(lower);
    }
  } else if (!nextMain && isAllowedMainKey(k)) { // I don't want to capture keys like F1~F12 and other special keys
    nextMain = k;
  }
  // Update state if changed
  if (nextMain !== mainKey) setMainKey(nextMain);
  if (nextMods.size !== modKeys.size || Array.from(nextMods).some((m) => !modKeys.has(m))) {
    setModKeys(nextMods);
  }
  // Attempt to finalize immediately once we have both parts
  attemptCommit(nextMods, nextMain);
};
Enter fullscreen mode Exit fullscreen mode

Display recorded keys

Displaying the result is straightforward. I also reorder the modifiers so users won’t see something like Shift + Ctrl + C (which feels unintuitive). The captured key names from the browser event are mapped to more readable labels.

function orderModifiers(mods: string[], platform: 'mac' | 'other') {
  const rankMac: Record<string, number> = { meta: 1, shift: 2, alt: 3, control: 4 };
  const rankOther: Record<string, number> = { control: 1, shift: 2, alt: 3, meta: 4 };
  const rank = platform === 'mac' ? rankMac : rankOther;
  return [...mods].sort((a, b) => (rank[a.toLowerCase()] ?? 99) - (rank[b.toLowerCase()] ?? 99));
}

function printModifier(k: string) {
  switch (k.toLowerCase()) {
    case 'meta':
      return 'Command';
    case 'alt':
      return 'Option';
    case 'control':
      return 'Ctrl';
    case 'shift':
      return 'Shift';
    default:
      return k;
  }
}

function normalizeMain(k: string) {
  if (k.length === 1) return /^[a-z]$/i.test(k) ? k.toUpperCase() : k;
  if (/^f\d{1,2}$/i.test(k)) return k.toUpperCase();
  const map: Record<string, string> = {
    escape: 'Esc',
    ' ': 'Space',
    arrowup: 'Up',
    arrowdown: 'Down',
    arrowleft: 'Left',
    arrowright: 'Right',
    enter: 'Enter',
    tab: 'Tab',
    backspace: 'Backspace',
  };
  return map[k.toLowerCase()] || k;
}

function formatDisplay(mods: string[], main: string | null, platform: 'mac' | 'other') {
  if (!main) return orderModifiers(mods, platform).map(printModifier).join('+');
  const ordered = orderModifiers(mods, platform).map(printModifier);
  return [...ordered, normalizeMain(main)].join('+');
}

Enter fullscreen mode Exit fullscreen mode

Demo

I added a 🔴 indicator to show whether the component is currently recording.

Demo

Here's the source code to the component.


A few years ago, I would have reached for a third-party library whenever I needed functionality like this. Now, with help from AI, I find it faster and cleaner to implement simple business logic myself.

If this sounds interesting, feel free to give SnapMind a try — I’d love to hear your feedback!

GitHub Repo: https://github.com/Snap-Mind/snap-mind

Top comments (0)