Appearance
Keyboard Workflows
Problem
Keyboard shortcuts are easy to add but easy to break:
- Shortcuts fire while typing in inputs
- Modals register Escape handlers inconsistently
- Multiple components bind the same shortcut
- Handlers become stale due to closure issues
Solution
Use useKeyboardShortcut (and useKeyboardShortcuts when needed) and model scope explicitly:
- Global shortcuts: registered at the app shell level
- Scoped shortcuts: register in the owning component and gate with
enabled - Default to
ignoreInputs: trueto avoid interfering with typing
Implementation
Global shortcut that toggles UI
tsx
import React from 'react'
import { useKeyboardShortcut, useToggle } from '@/shared/hooks'
export function ShortcutsHelp() {
const help = useToggle(false)
useKeyboardShortcut(
{ key: '?' },
() => help.toggle(),
{ ignoreInputs: true }
)
return (
<>
<button type="button" onClick={help.toggle}>Toggle help</button>
{help.value && <div role="dialog">Help content</div>}
</>
)
}Scoped shortcut enabled only while a modal is open
tsx
import { useKeyboardShortcut } from '@/shared/hooks'
export function Modal({ open, onClose }: { open: boolean; onClose: () => void }) {
useKeyboardShortcut({ key: 'Escape' }, onClose, { enabled: open })
return open ? <div role="dialog" aria-modal="true" /> : null
}Variations
- Allow shortcuts while typing
- Set
ignoreInputs: falsefor shortcuts like/to focus search. - Use sparingly; document the behavior.
- Set
- Multiple shortcuts in one place
- Use
useKeyboardShortcuts([...])if you want a single listener that routes multiple key combos.
- Use
Accessibility considerations
- Provide visible affordances for critical actions (buttons/menus) in addition to shortcuts.
- Avoid overriding browser/OS shortcuts unless absolutely necessary.
- Don’t assume capture-phase behavior: the listener is attached to
documentin the default phase; code that stops propagation can prevent a shortcut from firing.