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
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);
};
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('+');
}
Demo
I added a 🔴 indicator to show whether the component is currently recording.
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)