Keyboard Shortcuts
Implement accessible custom keyboard shortcuts
Custom keyboard shortcuts improve power user productivity but must be implemented accessibly. Follow conventions, avoid conflicts, and provide discoverability.
Basic Implementation
// hooks/useKeyboardShortcut.ts
export function useKeyboardShortcut(
key: string,
callback: () => void,
options: {
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean;
} = {}
) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const {
ctrlKey = options.ctrl || false,
shiftKey = options.shift || false,
altKey = options.alt || false,
metaKey = options.meta || false,
} = e;
if (
e.key.toLowerCase() === key.toLowerCase() &&
e.ctrlKey === ctrlKey &&
e.shiftKey === shiftKey &&
e.altKey === altKey &&
e.metaKey === metaKey
) {
e.preventDefault();
callback();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [key, callback, options]);
}
// Usage
export function Editor() {
const [content, setContent] = useState('');
useKeyboardShortcut('s', () => save(content), { ctrl: true });
useKeyboardShortcut('b', () => bold(), { ctrl: true });
useKeyboardShortcut('/', () => openSearch(), { ctrl: true });
return <textarea value={content} onChange={(e) => setContent(e.target.value)} />;
}Common Shortcuts
Standard Conventions
Cmd/Ctrl + S: Save
Cmd/Ctrl + C: Copy
Cmd/Ctrl + V: Paste
Cmd/Ctrl + Z: Undo
Cmd/Ctrl + Shift + Z: Redo
Cmd/Ctrl + F: Find
Cmd/Ctrl + K: Search/Command palette
Cmd/Ctrl + /: Toggle comment
Cmd/Ctrl + B: Bold
Cmd/Ctrl + I: Italic
Escape: Close modal/cancelApplication-Specific
export function AppWithShortcuts() {
useKeyboardShortcut('n', createNew, { ctrl: true });
useKeyboardShortcut('o', open, { ctrl: true });
useKeyboardShortcut('p', print, { ctrl: true });
useKeyboardShortcut('k', openCommandPalette, { ctrl: true });
useKeyboardShortcut('/', openSearch, { ctrl: true });
return <div>...</div>;
}Shortcut Manager
// utils/shortcuts.ts
type ShortcutHandler = () => void;
interface Shortcut {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
meta?: boolean;
description: string;
handler: ShortcutHandler;
}
class ShortcutManager {
private shortcuts: Map<string, Shortcut> = new Map();
register(shortcut: Shortcut) {
const id = this.getShortcutId(shortcut);
this.shortcuts.set(id, shortcut);
}
unregister(key: string, modifiers: any) {
const id = this.getShortcutId({ key, ...modifiers } as Shortcut);
this.shortcuts.delete(id);
}
getShortcutId(shortcut: Partial<Shortcut>): string {
const parts = [];
if (shortcut.ctrl) parts.push('ctrl');
if (shortcut.shift) parts.push('shift');
if (shortcut.alt) parts.push('alt');
if (shortcut.meta) parts.push('meta');
parts.push(shortcut.key?.toLowerCase());
return parts.join('+');
}
handleKeyDown = (e: KeyboardEvent) => {
const id = this.getShortcutId({
key: e.key,
ctrl: e.ctrlKey,
shift: e.shiftKey,
alt: e.altKey,
meta: e.metaKey,
});
const shortcut = this.shortcuts.get(id);
if (shortcut) {
// Don't trigger if typing in input
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target as HTMLElement).isContentEditable
) {
return;
}
e.preventDefault();
shortcut.handler();
}
};
getAllShortcuts(): Shortcut[] {
return Array.from(this.shortcuts.values());
}
}
export const shortcutManager = new ShortcutManager();
// Initialize listener
if (typeof window !== 'undefined') {
document.addEventListener('keydown', shortcutManager.handleKeyDown);
}Shortcut Context Provider
// components/ShortcutProvider.tsx
const ShortcutContext = createContext<ShortcutManager | null>(null);
export function ShortcutProvider({ children }: Props) {
const [manager] = useState(() => new ShortcutManager());
useEffect(() => {
const handler = (e: KeyboardEvent) => manager.handleKeyDown(e);
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [manager]);
return (
<ShortcutContext.Provider value={manager}>
{children}
</ShortcutContext.Provider>
);
}
export function useShortcut(shortcut: Shortcut) {
const manager = useContext(ShortcutContext);
useEffect(() => {
if (!manager) return;
manager.register(shortcut);
return () => {
manager.unregister(shortcut.key, shortcut);
};
}, [manager, shortcut]);
}Keyboard Shortcut Menu
// components/ShortcutMenu.tsx
export function ShortcutMenu() {
const [isOpen, setIsOpen] = useState(false);
const shortcuts = shortcutManager.getAllShortcuts();
useKeyboardShortcut('?', () => setIsOpen(true), { shift: true });
const formatShortcut = (shortcut: Shortcut) => {
const keys = [];
if (shortcut.meta) keys.push('⌘');
if (shortcut.ctrl) keys.push('Ctrl');
if (shortcut.shift) keys.push('Shift');
if (shortcut.alt) keys.push('Alt');
keys.push(shortcut.key.toUpperCase());
return keys.join(' + ');
};
return (
<>
<button
onClick={() => setIsOpen(true)}
aria-label="View keyboard shortcuts"
title="Keyboard shortcuts (Shift + ?)"
>
⌨️
</button>
{isOpen && (
<Modal onClose={() => setIsOpen(false)}>
<h2>Keyboard Shortcuts</h2>
<table>
<thead>
<tr>
<th>Shortcut</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{shortcuts.map((shortcut) => (
<tr key={shortcutManager.getShortcutId(shortcut)}>
<td>
<kbd>{formatShortcut(shortcut)}</kbd>
</td>
<td>{shortcut.description}</td>
</tr>
))}
</tbody>
</table>
</Modal>
)}
</>
);
}
// CSS for kbd
kbd {
background: #f4f4f4;
border: 1px solid #ccc;
border-radius: 4px;
padding: 2px 6px;
font-family: monospace;
font-size: 0.9em;
}Command Palette
// components/CommandPalette.tsx
export function CommandPalette() {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(0);
useKeyboardShortcut('k', () => setIsOpen(true), { ctrl: true });
const commands = [
{ id: 'new', label: 'New Document', shortcut: 'Ctrl+N', action: createNew },
{ id: 'save', label: 'Save', shortcut: 'Ctrl+S', action: save },
{ id: 'search', label: 'Search', shortcut: 'Ctrl+/', action: search },
// ...more commands
];
const filteredCommands = commands.filter(cmd =>
cmd.label.toLowerCase().includes(query.toLowerCase())
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelected(prev => Math.min(prev + 1, filteredCommands.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelected(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
filteredCommands[selected]?.action();
setIsOpen(false);
}
};
if (!isOpen) return null;
return (
<Modal onClose={() => setIsOpen(false)}>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a command..."
autoFocus
/>
<ul role="listbox">
{filteredCommands.map((cmd, index) => (
<li
key={cmd.id}
role="option"
aria-selected={index === selected}
onClick={() => {
cmd.action();
setIsOpen(false);
}}
>
<span>{cmd.label}</span>
<kbd>{cmd.shortcut}</kbd>
</li>
))}
</ul>
</Modal>
);
}Contextual Shortcuts
// Different shortcuts based on context
export function Editor() {
const [mode, setMode] = useState<'edit' | 'view'>('view');
// Edit mode shortcuts
useShortcut({
key: 's',
ctrl: true,
description: 'Save',
handler: save,
});
// View mode shortcuts
useKeyboardShortcut('e', () => setMode('edit'));
useKeyboardShortcut('j', scrollDown);
useKeyboardShortcut('k', scrollUp);
return (
<div data-mode={mode}>
{mode === 'edit' ? <EditMode /> : <ViewMode />}
</div>
);
}Accessibility Considerations
Screen Reader Announcements
export function withShortcutAnnouncement(Component: React.ComponentType) {
return function WithAnnouncement(props: any) {
const [announcement, setAnnouncement] = useState('');
const announceShortcut = (action: string) => {
setAnnouncement(`${action} executed`);
setTimeout(() => setAnnouncement(''), 2000);
};
return (
<>
<Component {...props} announceShortcut={announceShortcut} />
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
</>
);
};
}Avoid Input Conflicts
function shouldIgnoreShortcut(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement;
// Ignore in input fields
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return true;
}
// Ignore if modifier combination is unusual
// (might be browser/OS shortcut)
if (e.altKey && e.ctrlKey) return true;
return false;
}Discoverability
// Show shortcuts in tooltips
export function Button({ onClick, shortcut }: Props) {
return (
<button
onClick={onClick}
title={`Save (${shortcut})`}
aria-label={`Save, keyboard shortcut: ${shortcut}`}
>
Save
</button>
);
}
// Show in menus
export function Menu() {
return (
<ul role="menu">
<li role="menuitem">
<span>New</span>
<kbd>Ctrl+N</kbd>
</li>
<li role="menuitem">
<span>Save</span>
<kbd>Ctrl+S</kbd>
</li>
</ul>
);
}Testing
describe('Keyboard Shortcuts', () => {
it('saves with Ctrl+S', () => {
render(<Editor />);
fireEvent.keyDown(document, {
key: 's',
ctrlKey: true,
});
expect(mockSave).toHaveBeenCalled();
});
it('ignores shortcuts in input fields', () => {
render(<Editor />);
const input = screen.getByRole('textbox');
input.focus();
fireEvent.keyDown(input, {
key: 's',
ctrlKey: true,
});
expect(mockSave).not.toHaveBeenCalled();
});
});Best Practices
- Follow conventions: Use standard shortcuts (Ctrl+S, Ctrl+C, etc.)
- Document shortcuts: Help menu, tooltips
- Visual indicators: Show shortcuts in UI
- Avoid conflicts: Don't override browser shortcuts
- Context-aware: Different shortcuts for different modes
- Announce actions: Screen reader feedback
- Escape always closes: Standard behavior
- Test thoroughly: All shortcuts, all contexts
- Allow customization: Power users love this
- Cross-platform: Cmd on Mac, Ctrl on Windows
Common Pitfalls
❌ Overriding browser shortcuts: Breaks user expectations
✅ Use unique combinations or standard conventions
❌ No discoverability: Users don't know shortcuts exist
✅ Show in tooltips, menus, help dialog
❌ Ignoring inputs: Shortcuts trigger while typing
✅ Check if target is input/textarea
❌ No screen reader support: Silent shortcuts
✅ Announce actions with live regions
❌ Platform-specific only: Ctrl on Mac breaks expectations
✅ Detect platform, use Cmd on Mac
Keyboard shortcuts boost productivity—implement them accessibly with clear documentation and smart defaults!