Skip to content
tactile

Recipes & examples

Copy-paste starting points for real features.

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>
);
}

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),
});

Focus sets the scope; when gates the rule. The full pattern — single active scope, multiple scope flags, and the modal variant — lives in useScope.

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.

// mod+k is usually enough — mod resolves per platform
kb.add({ id: 'palette', keys: 'mod+k', handler: open });
// Explicit alternatives when needed
kb.add({
id: 'palette',
keys: ['mod+k', 'ctrl+p'], // either works
handler: open,
});
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),
);
}

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 });

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.

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
},
});
// Default binding
kb.add({ id: 'palette.default', keys: 'mod+k', handler: defaultPalette });
// Extension overrides with higher priority
kb.add({
id: 'palette.extension',
keys: 'mod+k',
priority: 10,
handler: extensionPalette,
});
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.