Overview
Keybinding registration and keyboard event routing
Quick start
import { useFocusNode, useKeybindings } from 'giggles';
function FileList() {
const focus = useFocusNode();
const [selected, setSelected] = useState(0);
const files = ['index.ts', 'utils.ts', 'types.ts'];
useKeybindings(focus, {
j: () => setSelected(i => Math.min(files.length - 1, i + 1)),
k: () => setSelected(i => Math.max(0, i - 1)),
});
return (
<Box flexDirection="column">
{files.map((file, i) => (
<Text key={file} color={i === selected ? 'green' : 'white'}>{file}</Text>
))}
</Box>
);
}How a keypress reaches its handler
When a key is pressed, giggles walks up the focus tree from the currently focused component toward the root, checking each node for a matching handler. The first match wins; unmatched keys keep moving up. Components outside the focused path never receive input.
This is what makes bindings local: the same key can be bound differently at every nesting level, and only the focused path's bindings are consulted. Register app-wide shortcuts on the outermost scope — they fire whenever nothing deeper claims the key first.
Registering keybindings
useKeybindings takes a focus handle as its first argument — bindings only fire when that component is in the active focus path. It works with any focus handle: nodes from useFocusNode, or scopes from useFocusScope.
For scopes, there's an alternative: the keybindings option on useFocusScope itself. The difference is that the option gives you the navigation helpers — next, prev, drillIn, and friends — which aren't available through useKeybindings. Use the option when you need to navigate between children; use useKeybindings for everything else: action bindings, fallback, bubble, or adding bindings to a scope that already has navigation set up.
useKeybindings(focus, {
j: moveDown,
k: moveUp,
enter: select,
});You can call useKeybindings multiple times in the same component. Bindings from all calls are merged; later calls override earlier ones for duplicate keys. This lets you split navigation and mode-specific bindings into separate calls, which makes conditional spreading cleaner:
useKeybindings(focus, {
...(!editing && { j: moveDown, k: moveUp }),
escape: exitMode,
});
useKeybindings(focus, editing ? { enter: confirmEdit } : {});Text input and the fallback
Named bindings work well for discrete actions, but they can't capture arbitrary typed characters. For that, use the fallback option — a handler that receives any keystroke that doesn't match a named binding:
useKeybindings(focus, { escape: exitSearch }, {
fallback: (input, key) => {
if (key.backspace) setQuery(q => q.slice(0, -1));
else if (input) setQuery(q => q + input);
}
});Some keys should always reach their named binding even when a fallback is active. Use bubble to let specific keys skip the fallback and propagate normally:
useKeybindings(focus, { escape: exitSearch }, {
fallback: handleInput,
bubble: ['escape', 'enter'],
});Named bindings always take priority over the fallback across all useKeybindings calls on the same component. If a named binding and the fallback need to compete for the same key at different times, disable the binding conditionally: ...(!searchMode && { j: moveDown }).
Named bindings and the registry
Bindings can carry a human-readable name, making them discoverable for help screens, command palettes, and key hint bars:
useKeybindings(focus, {
j: { action: moveDown, name: 'Move down' },
k: { action: moveUp, name: 'Move up' },
'/': { action: openSearch, name: 'Search' },
});useKeybindingRegistry reads all named bindings filtered by the current focus path:
function HelpScreen() {
const registry = useKeybindingRegistry();
return (
<Box flexDirection="column">
{registry.available.map((cmd) => (
<Box key={`${cmd.nodeId}-${cmd.key}`} gap={2}>
<Text color="cyan">{cmd.key}</Text>
<Text>{cmd.name}</Text>
</Box>
))}
</Box>
);
}available returns only the bindings reachable from the current focus path — so the help screen always reflects what's actually active, not everything registered across the entire app.
Trapping focus
Key bubbling normally walks all the way to the root. For modal dialogs and confirmation prompts, you may want to cut that path entirely — nothing outside the modal should receive input until it's dismissed. FocusTrap does this: mount it and all input is confined to its subtree. Unmount it and focus returns to where it was before.
function App() {
const [showModal, setShowModal] = useState(false);
return (
<>
<MainContent />
{showModal && (
<FocusTrap>
<Modal onClose={() => setShowModal(false)} />
</FocusTrap>
)}
</>
);
}- API Reference — full type signatures for
useKeybindings,useKeybindingRegistry, andFocusTrap - Examples — complete worked examples for common input patterns