Recipes & examples
Copy-paste starting points for real features.
Command palette
Section titled “Command palette”import { useEffect, useState } from 'react';import { KeybindProvider, useShortcut, useEngine } from '@tactile-js/react';
function App() { return ( <KeybindProvider> <Palette /> </KeybindProvider> );}
function Palette() { const engine = useEngine(); const [open, setOpen] = useState(false);
useShortcut({ id: 'palette.toggle', keys: 'mod+k', group: 'Navigation', description: 'Toggle command palette', handler: () => setOpen((o) => !o), });
// Close on Escape useShortcut({ id: 'palette.close', keys: 'escape', when: 'paletteOpen', handler: () => setOpen(false), });
// Sync context for when-gated close shortcut useEffect(() => { if (open) engine.context.set('paletteOpen', true); else engine.context.delete('paletteOpen'); }, [open, engine]);
if (!open) return null;
return ( <dialog open> <input placeholder="Type a command…" autoFocus /> {/* filter commands, run on Enter */} </dialog> );}Shortcuts help dialog
Section titled “Shortcuts help dialog”Use useKeymap so the dialog can’t drift from registered bindings:
import { useKeymap } from '@tactile-js/react';
function ShortcutsHelp({ onClose }: { onClose: () => void }) { const keymap = useKeymap({ forContext: true }); // only active shortcuts
useShortcut({ id: 'help.close', keys: 'escape', handler: onClose });
const groups = Map.groupBy(keymap, (r) => r.group ?? 'Other');
return ( <dialog open> <h2>Keyboard shortcuts</h2> {[...groups.entries()].map(([group, rules]) => ( <section key={group}> <h3>{group}</h3> <ul> {rules.map((r) => ( <li key={r.id}> <kbd>{r.labels.join(' / ')}</kbd> <span>{r.description ?? r.id}</span> {r.when && <code>{r.when}</code>} </li> ))} </ul> </section> ))} </dialog> );}Toggle with ? or mod+shift+slash:
useShortcut({ id: 'help.toggle', keys: '?', description: 'Show keyboard shortcuts', handler: () => setHelpOpen((o) => !o),});Scoped editor shortcuts
Section titled “Scoped editor shortcuts”Focus sets the scope; when gates the rule. The full pattern — single active scope, multiple scope
flags, and the modal variant — lives in useScope.
Sequences (Gmail-style)
Section titled “Sequences (Gmail-style)”kb.add({ id: 'go.inbox', keys: 'g i', description: 'Go to inbox', handler: () => router.push('/inbox'),});Single-letter prefixes (g alone) won’t fire unless you register them — only complete sequences
match. The stroke gap is configurable via
sequenceTimeout.
Alternative bindings (cross-platform)
Section titled “Alternative bindings (cross-platform)”// mod+k is usually enough — mod resolves per platformkb.add({ id: 'palette', keys: 'mod+k', handler: open });
// Explicit alternatives when neededkb.add({ id: 'palette', keys: ['mod+k', 'ctrl+p'], // either works handler: open,});User rebinding UI
Section titled “User rebinding UI”function RebindField({ onSave }: { onSave: (binding: string) => void }) { const { isRecording, combo, start, stop } = useShortcutRecorder();
return ( <div> <button onClick={isRecording ? stop : start}> {isRecording ? 'Press keys…' : 'Change shortcut'} </button> {combo && ( <p> Captured: <kbd>{combo.label}</kbd> <button onClick={() => onSave(combo.binding)}>Save</button> </p> )} </div> );}Check collisions before saving:
function wouldCollide(engine: KeybindingEngine, binding: string, id: string) { return engine.getCollisions().some( (c) => c.keys === binding && c.rules.some((r) => r.id !== id), );}Simulating key presses in tests
Section titled “Simulating key presses in tests”The source option makes the engine fully testable
in Node — inject an in-memory source and drive it yourself:
import { createKeybindingEngine, type KeyEvent } from '@tactile-js/core';
const listeners = new Map<string, Set<(e: KeyEvent) => void>>();
const kb = createKeybindingEngine({ source: { on(type, listener) { const set = listeners.get(type) ?? new Set(); set.add(listener); listeners.set(type, set); return () => set.delete(listener); }, },});
kb.add({ id: 'save', keys: 'mod+s', handler: save });
function press(e: KeyEvent) { for (const fn of listeners.get('keydown') ?? []) fn(e);}
press({ key: 's', code: 'KeyS', metaKey: true, ctrlKey: false, shiftKey: false, altKey: false });Input fields
Section titled “Input fields”For the palette-must-open-anywhere case, set enableInFormFields: true on that rule and you’re
done. All strategies — the per-rule flag, default ignore, per-shortcut when gating, and selective
passthrough — with code for each in
Engine options → ignore.
Handler: skip preventDefault
Section titled “Handler: skip preventDefault”kb.add({ id: 'log.key', keys: 'mod+shift+l', handler: (event, info) => { console.log(info.id, info.keys); return false; // let the browser handle it too },});Dynamic priority override
Section titled “Dynamic priority override”// Default bindingkb.add({ id: 'palette.default', keys: 'mod+k', handler: defaultPalette });
// Extension overrides with higher prioritykb.add({ id: 'palette.extension', keys: 'mod+k', priority: 10, handler: extensionPalette,});Vanilla JS (no React)
Section titled “Vanilla JS (no React)”import { createKeybindingEngine } from '@tactile-js/core';
const kb = createKeybindingEngine();
kb.add({ id: 'save', keys: 'mod+s', handler: () => save() });
document.getElementById('editor')?.addEventListener('focus', () => { kb.context.set('scope', 'editor');});See the React hooks for the component lifecycle equivalent.