use-kbd
v0.8.0
Published
Omnibars, editable hotkeys, search, and keyboard-navigation for React apps
Maintainers
Readme
use-kbd
Omnibars, editable hotkeys, search, and keyboard-navigation for React apps.
Also in production at ctbk.dev and awair.runsascoded.com.
Quick Start
npm install use-kbd # or: pnpm add use-kbdimport { HotkeysProvider, ShortcutsModal, Omnibar, LookupModal, SequenceModal, useAction } from 'use-kbd'
import 'use-kbd/styles.css'
function App() {
return (
<HotkeysProvider>
<Dashboard />
<ShortcutsModal /> {/* "?" modal: view/edit key-bindings */}
<Omnibar /> {/* "⌘K" omnibar: search and select actions */}
<LookupModal /> {/* "⌘⇧K": look up actions by key-binding */}
<SequenceModal /> {/* Inline display for key-sequences in progress */}
</HotkeysProvider>
)
}
function Dashboard() {
const { save } = useDocument() // Function to expose via hotkeys / omnibar
// Wrap function as "action", with keybinding(s) and omnibar keywords
useAction('doc:save', {
label: 'Save document',
group: 'Document',
defaultBindings: ['meta+s'],
handler: save,
})
return <Editor />
}Basic steps
- Drop-in UI components:
ShortcutsModal: view/edit key-bindingsOmnibar: search and select actionsLookupModal: look up actions by key-bindingSequenceModal: autocomplete multi-key sequences
- Register functions as "actions" with
useAction - Easy theming with CSS variables
Core Concepts
Actions
Register any function with useAction:
useAction('view:toggle-sidebar', {
label: 'Toggle sidebar',
group: 'View',
defaultBindings: ['meta+b', 'meta+\\'],
keywords: ['panel', 'navigation'],
handler: () => setSidebarOpen(prev => !prev),
})Actions automatically unregister when the component unmounts—no cleanup needed.
Conditionally disable actions with enabled:
useAction('doc:save', {
label: 'Save',
defaultBindings: ['meta+s'],
enabled: hasUnsavedChanges, // Action hidden when false
handler: save,
})Protect essential bindings from removal with protected:
useAction('app:shortcuts', {
label: 'Show shortcuts',
defaultBindings: ['?'],
protected: true, // Users can add bindings, but can't remove this one
handler: () => openShortcutsModal(),
})Sequences
Multi-key sequences like Vim's g g (go to top) are supported:
useAction('nav:top', {
label: 'Go to top',
defaultBindings: ['g g'], // Press g, then g again
handler: () => scrollToTop(),
})The SequenceModal shows available completions while typing a sequence.
Key Aliases
For convenience, common key names have shorter aliases:
| Alias | Key |
|-------|-----|
| left, right, up, down | Arrow keys |
| esc | escape |
| del | delete |
| return | enter |
| pgup, pgdn | pageup, pagedown |
useAction('nav:prev', {
label: 'Previous item',
defaultBindings: ['left', 'h'], // 'left' = 'arrowleft'
handler: () => selectPrev(),
})User Customization
Users can edit bindings in the ShortcutsModal. Changes persist to localStorage using the storageKey you provide.
Components
<HotkeysProvider>
Wrap your app to enable the hotkeys system:
<HotkeysProvider config={{
storageKey: 'use-kbd', // localStorage key for user overrides (default)
sequenceTimeout: Infinity, // ms before sequence times out (default: no timeout)
disableConflicts: false, // Disable keys with multiple actions (default: false)
enableOnTouch: false, // Enable hotkeys on touch devices (default: false)
}}>
{children}
</HotkeysProvider>Note: Modal/omnibar trigger bindings are configured via component props (defaultBinding), not provider config.
<ShortcutsModal>
Displays all registered actions grouped by category. Users can click bindings to edit them on desktop.
<ShortcutsModal groups={[
{ id: 'nav', label: 'Navigation' },
{ id: 'edit', label: 'Editing' },
]} /><Omnibar>
Command palette for searching and executing actions:
<Omnibar
placeholder="Type a command..."
maxResults={10}
/><LookupModal>
Browse and filter shortcuts by typing key sequences. Press ⌘⇧K (default) to open.
<LookupModal defaultBinding="meta+shift+k" />Open programmatically with pre-filled keys via context:
const { openLookup } = useHotkeysContext()
// Open with "g" already typed (shows all "g ..." sequences)
openLookup([{ key: 'g', modifiers: { ctrl: false, alt: false, shift: false, meta: false } }])<SequenceModal>
Shows pending keys and available completions during sequence input. No props needed—it reads from context.
<SequenceModal />Styling
Import the default styles:
import 'use-kbd/styles.css'Customize with CSS variables:
.kbd-modal,
.kbd-omnibar,
.kbd-sequence {
--kbd-bg: #1f2937;
--kbd-text: #f3f4f6;
--kbd-border: #4b5563;
--kbd-accent: #3b82f6;
--kbd-kbd-bg: #374151;
}Dark mode is automatically applied via [data-theme="dark"] or .dark selectors.
See awair's use-kbd-demo branch for a real-world integration example.
Mobile Support
While keyboard shortcuts are primarily a desktop feature, use-kbd provides solid mobile UX out of the box. Try the demos on your phone →
What works on mobile:
- Omnibar search – Tap the search icon or
⌘Kbadge to open, then search and execute actions - LookupModal – Browse shortcuts by typing on the virtual keyboard
- ShortcutsModal – View all available shortcuts (editing disabled since there's no physical keyboard)
- Back button/swipe – Native gesture closes modals
- Responsive layouts – All components adapt to small screens
Demo-specific features:
- Table demo – Tap search icon in the floating controls to open omnibar
- Canvas demo – Touch-to-draw support alongside keyboard shortcuts
For apps that want keyboard shortcuts on desktop but still need the omnibar/search on mobile, this covers the common case without extra configuration.
Patterns
ActionLink
Make navigation links discoverable in the omnibar by registering them as actions. Here's a reference implementation for react-router:
import { useEffect, useRef } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useMaybeHotkeysContext } from 'use-kbd'
interface ActionLinkProps {
to: string
label?: string
group?: string
keywords?: string[]
defaultBinding?: string
children: React.ReactNode
}
export function ActionLink({
to,
label,
group = 'Navigation',
keywords,
defaultBinding,
children,
}: ActionLinkProps) {
const ctx = useMaybeHotkeysContext()
const navigate = useNavigate()
const location = useLocation()
const isActive = location.pathname === to
const effectiveLabel = label ?? (typeof children === 'string' ? children : to)
const actionId = `nav:${to}`
// Use ref to avoid re-registration on navigate change
const navigateRef = useRef(navigate)
navigateRef.current = navigate
useEffect(() => {
if (!ctx?.registry) return
ctx.registry.register(actionId, {
label: effectiveLabel,
group,
keywords,
defaultBindings: defaultBinding ? [defaultBinding] : [],
handler: () => navigateRef.current(to),
enabled: !isActive, // Hide from omnibar when on current page
})
return () => ctx.registry.unregister(actionId)
}, [ctx?.registry, actionId, effectiveLabel, group, keywords, defaultBinding, isActive, to])
return <Link to={to}>{children}</Link>
}Usage:
<ActionLink to="/docs" keywords={['help', 'guide']}>Documentation</ActionLink>
<ActionLink to="/settings" defaultBinding="g s">Settings</ActionLink>Adapt for Next.js, TanStack Router, or other routers by swapping the router hooks.
Low-Level Hooks
For advanced use cases, the underlying hooks are also exported:
useHotkeys(keymap, handlers, options?)
Register shortcuts directly without the provider:
useHotkeys(
{ 't': 'setTemp', 'meta+s': 'save' },
{ setTemp: () => setMetric('temp'), save: handleSave }
)useRecordHotkey(options?)
Capture key combinations from user input:
const { isRecording, startRecording, display } = useRecordHotkey({
onCapture: (sequence, display) => saveBinding(display.id),
})useEditableHotkeys(defaults, handlers, options?)
Wraps useHotkeys with localStorage persistence and conflict detection.
Inspiration
- macOS and GDrive menu search
- Superhuman omnibar
- Vimium keyboard-driven browsing
- Android searchable settings
License
MIT
