npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vimium-trap

v0.1.0

Published

Stop Vimium, Tridactyl, Surfingkeys, and Firefox search-when-you-type from stealing keyboard input from canvas web games.

Readme

vimium-trap

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)

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