Stop Vimium, Vimium C, Tridactyl, Surfingkeys, and Firefox's search when you type from stealing keyboard input from your web game.
trapKeys(canvas) and done.
Vim-style extensions treat a focused <canvas> as a normal page and intercept hjkl, f, x, … The fix is folk knowledge: make the focused element contenteditable so they enter insert-mode passthrough. This package is the small, tested version of that trick — extracted from shipping web exports (Godot and canvas games) so itch jammers don't have to rediscover it.
Not
focus-trap. That library contains focus inside modals for accessibility. This one keeps game keys out of vim extensions.
<canvas id="game"></canvas>
<script src="https://unpkg.com/vimium-trap"></script>
<script>
trapKeys(document.getElementById('game'));
</script>npm install vimium-trapimport { trapKeys } from 'vimium-trap';
const handle = trapKeys(document.querySelector('canvas'));
// later: handle.release();function trapKeys(el: HTMLElement, opts?: TrapOptions): TrapHandle;
interface TrapOptions {
/** 'always' (default) | 'gated' | 'manual' — see below */
refocus?: 'always' | 'gated' | 'manual';
/** Inject caret-color:transparent / outline:none. Default true. */
injectStyles?: boolean;
/** Set inputmode="none" (suppress mobile keyboards). Default true. */
suppressVirtualKeyboard?: boolean;
/** Fired when the trap regains focus. */
onRefocus?: () => void;
}
interface TrapHandle {
release(): void; // restore attributes & remove listeners
focus(): void; // re-assert focus (e.g. after your own modal)
readonly active: boolean;
}| Value | Behavior |
|---|---|
'always' |
On any blur, reclaim focus next animation frame. Right for a dedicated fullscreen game page. Default. |
'gated' |
Reclaim only if focus fell to <body> / null (or the trap itself). Links, chat inputs, and other page UI stay usable. |
'manual' |
Never auto-refocus. Call handle.focus() yourself. |
'always' monopolizes focus — if your page has other interactive UI, use 'gated'.
The trap makes the element look editable. Set role="application" and an aria-label (e.g. your game's name) on the canvas yourself so AT announces an application, not a text field:
canvas.setAttribute('role', 'application');
canvas.setAttribute('aria-label', 'Boxed In');
trapKeys(canvas);Extensions special-case focused editable elements (inputs, textareas, contenteditable) and pass keys through — insert mode by design. Page-level preventDefault on keydown does not help: content scripts see events first. Exclusion lists need per-user config and break on itch CDN iframe domains. Focused-editable is the signal that works for every player with zero config.
trapKeys sets contenteditable, hides the caret, blocks editing side effects (beforeinput, paste, …) without touching keydown/keyup, and refocuses after Escape blurs insert mode so the next keystroke still reaches the game.
- Escape is gone. Extensions swallow Esc to leave insert mode and never forward it. The trap recovers focus immediately, but the game never sees that press. Don't make Esc the only pause bind on web (offer
P/Tab). First-person games already lose Esc to pointer-lock exit. - Browser-level shortcuts (e.g. some Vimium C create-tab bindings) fire before page focus matters — out of scope.
refocus: 'always'steals focus from every other control. Use'gated'when the page has UI outside the canvas.
// Godot 4 (custom HTML shell) — export template may also focus #canvas; trap coexists
trapKeys(document.getElementById('canvas'));
// Unity WebGL
trapKeys(document.getElementById('unity-canvas'));
// Emscripten / raw wasm
trapKeys(Module.canvas);
// Phaser 3 (after boot)
trapKeys(game.canvas);Games on itch.io run in an iframe (*.hwcdn.net / itch.zone). Content scripts run in all frames; the trap works inside the iframe — no itch-side config.
Full source — blessed for vendoring
function trapKeys(el, opts = {}) {
const refocus = opts.refocus ?? 'always';
const injectStyles = opts.injectStyles ?? true;
const suppressVirtualKeyboard = opts.suppressVirtualKeyboard ?? true;
const onRefocus = opts.onRefocus;
const ATTRS = ['contenteditable', 'spellcheck', 'autocapitalize', 'inputmode', 'tabindex'];
const prior = new Map(ATTRS.map((n) => [n, el.getAttribute(n)]));
const priorOutline = el.style.outline;
const priorCaretColor = el.style.caretColor;
el.setAttribute('contenteditable', 'true');
el.setAttribute('spellcheck', 'false');
el.setAttribute('autocapitalize', 'off');
if (suppressVirtualKeyboard) el.setAttribute('inputmode', 'none');
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
if (injectStyles) {
el.style.outline = 'none';
el.style.caretColor = 'transparent';
}
let active = true;
let raf = 0;
const claim = (notify) => {
if (!active) return;
if (document.activeElement !== el) {
el.focus({ preventScroll: true });
if (notify) onRefocus?.();
}
};
const shouldReclaim = () => {
if (refocus === 'manual') return false;
if (refocus === 'always') return true;
const ae = document.activeElement;
return !ae || ae === document.body || ae === el;
};
const onPointerDown = () => claim(true);
const onBlur = () => {
if (!active || refocus === 'manual') return;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
if (active && shouldReclaim()) claim(true);
});
};
const prevent = (e) => e.preventDefault();
const onSelectionChange = () => {
if (!active) return;
const sel = getSelection();
if (sel?.anchorNode && el.contains(sel.anchorNode)) sel.removeAllRanges();
};
el.addEventListener('pointerdown', onPointerDown);
el.addEventListener('blur', onBlur);
for (const ev of ['beforeinput', 'paste', 'cut', 'drop', 'compositionstart']) {
el.addEventListener(ev, prevent);
}
document.addEventListener('selectionchange', onSelectionChange);
claim(false);
return {
focus: () => claim(true),
get active() { return active; },
release() {
if (!active) return;
active = false;
cancelAnimationFrame(raf);
el.removeEventListener('pointerdown', onPointerDown);
el.removeEventListener('blur', onBlur);
for (const ev of ['beforeinput', 'paste', 'cut', 'drop', 'compositionstart']) {
el.removeEventListener(ev, prevent);
}
document.removeEventListener('selectionchange', onSelectionChange);
for (const n of ATTRS) {
if (n === 'inputmode' && !suppressVirtualKeyboard) continue;
const v = prior.get(n);
if (v === null) el.removeAttribute(n);
else el.setAttribute(n, v);
}
if (injectStyles) {
el.style.outline = priorOutline;
el.style.caretColor = priorCaretColor;
}
},
};
}If you landed here from a forum thread: yes — this is the fix for "vimium eats my game's keyboard input", "itch.io game keyboard shortcuts not working", "canvas game vimium insert mode", and "Firefox search when you type game".
The library is ~40 lines. Trust comes from CI against real extensions, not from unit coverage.
| Extension | Version (pinned) | Browser | Control (untrapped) | Trap | Esc recovery |
|---|---|---|---|---|---|
| Vimium | v2.4.2 | Chromium | pass | pass | pass |
| Vimium C | v1.99.995 | Chromium | pass | pass | pass |
| Surfingkeys | 50c88ea | Chromium | pass | pass | pass |
| Tridactyl / Firefox typeaheadfind | — | Firefox | pending (Selenium) | — | — |
Built from upstream source at pins in e2e/extensions.lock.json. Every run asserts the extension works without the trap first — a silent failed load cannot produce a green matrix. Full table: COMPATIBILITY.md.
npm run test:e2e # all Chromium columns
npx playwright test --project=vimium-c # one columndemo/ — toggle the trap off with Vimium installed and watch hjkl/f get stolen; toggle on and the canvas keeps them.
npm run demo:sync
npx serve demoMIT