Skip to content

Repository files navigation

vimium-trap

npm CI

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.

Quick start (script tag)

<canvas id="game"></canvas>
<script src="https://unpkg.com/vimium-trap"></script>
<script>
  trapKeys(document.getElementById('game'));
</script>

Install

npm install vimium-trap
import { trapKeys } from 'vimium-trap';

const handle = trapKeys(document.querySelector('canvas'));
// later: handle.release();

API

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

refocus policies

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'.

Screen readers

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

Why this works

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.

Known limitations

  1. 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.
  2. Browser-level shortcuts (e.g. some Vimium C create-tab bindings) fire before page focus matters — out of scope.
  3. refocus: 'always' steals focus from every other control. Use 'gated' when the page has UI outside the canvas.

Engine snippets

// 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.

Copy-paste (no npm)

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

Related searches

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".

Compatibility matrix

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 column

Demo

demo/ — 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 demo

License

MIT

Releases

Packages

Contributors

Languages