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

@nocksock/keymap

v0.42.0

Published

A tiny keyboard-shortcut dispatcher: bind keys (and multi-key sequences) to actions, with stackable layers for modal UIs.

Readme

keymap

A tiny keyboard-shortcut dispatcher: bind keys (and multi-key sequences) to actions, with stackable layers for modal UIs.

  • ~3.00 kB minified, ~1.44 kB gzipped
  • Zero dependencies
  • Single-key, modifier, and multi-key Vim-style sequences (g g, d w)
  • Stackable layers (push/pop) for modes and contextual overrides
  • Works with raw KeyboardEvents, with preventDefault handled for you

Install

import { Keymap } from "@nocksock/keymap"

Quick start

const editor = new Keymap({
  'j': () => moveDown(),
  'k': () => moveUp(),
  'ctrl+s': () => save(),
})

document.addEventListener('keydown', editor.handleKeyboardEvent)

Bindings

A binding map pairs a key string with an action. An action is either a plain function or an object with an effect:

const km = new Keymap({
  'a': () => doThing(),
  'b': { group: 'edit', description: 'do other thing', effect: () => doOther() },
})

Passing anything else (e.g. a number) throws:

new Keymap({ 'a': 123 }) // throws

Key syntax

| Form | Example | Matches | |------|---------|---------| | Single key | 'a' | the a key | | Modifier | 'ctrl+b', 'shift+h' | modifier + key | | Named key | 'escape', 'space' | Escape, the spacebar | | Sequence | 'g g', 'g a b' | keys pressed in order |

Modifiers and named keys register in canonical lowercase. A Shift+H press matches a 'shift+h' binding; a spacebar press matches 'space'.

Dispatching keys

type(key, ctx?)

Feeds one key to the map and runs the matching action. The key can be a string or a KeyboardEvent-shaped object.

km.type('a')
km.type('ctrl+b')
km.type(escapeEvent) // { key: 'Escape', ... }

type returns the outcome:

| Result | Meaning | |--------|---------| | 'handled' | a binding matched and fired | | 'unhandled' | nothing matched | | 'pending' | a multi-key sequence is partway through |

The result comes back synchronously, so you can drive UI from it — surface a which-key-style hint or a "waiting for the next key" indicator while a sequence is 'pending', clear it on 'handled', and optionally flash feedback on 'unhandled'.

const km = new Keymap({ 'g g': goTop, 'g e': goEnd })

km.type('g') // 'pending' — waiting for the next key
km.type('g') // 'handled' — fires goTop

A broken sequence resets and reports 'unhandled':

km.type('g') // 'pending'
km.type('x') // 'unhandled' — 'g x' matches nothing; buffer resets

Sequences always wait on a prefix

A key that is a prefix of a longer binding waits instead of firing early — even when it is the only candidate. This is what makes operator + motion (e.g. d w) work:

const km = new Keymap({ 'd w': deleteWord })

km.type('d') // 'pending' — never fires on its own
km.type('w') // 'handled' — fires deleteWord

The same holds when one map defines both a key and a longer sequence starting with it:

const km = new Keymap({ 'g': goLine, 'g g': goTop })

km.type('g') // 'pending' — won't fire goLine early
km.type('g') // 'handled' — fires goTop

handleKeyboardEvent(event)

The DOM entry point. It dispatches the event and calls preventDefault() for matched keys (see below). Lone modifier presses (a bare Shift, Ctrl, …) are ignored and never pollute a pending sequence:

km.handleKeyboardEvent(gEvent)     // 'pending'
km.handleKeyboardEvent(shiftEvent) // ignored
km.handleKeyboardEvent(gEvent)     // completes 'g g'

handleKeyboardEvent is permanently bound to its keymap, so you can pass it straight to addEventListener / removeEventListener — no wrapping arrow, no .bind. Attach it to document, window, or any element, and detaching is symmetric:

// in a custom element
connectedCallback() {
  this.addEventListener('keydown', this.km.handleKeyboardEvent)
}
disconnectedCallback() {
  this.removeEventListener('keydown', this.km.handleKeyboardEvent)
}

Context and the effect signature

Effects receive a context object. Anything you pass as the second type argument is exposed as context:

const km = new Keymap({ 'a': (ctx) => console.log(ctx.context) })
km.type('a', { selection: '…' }) // ctx.context === { selection: '…' }

Object-form bindings receive the same context as plain functions.

preventDefault

Matched keys call event.preventDefault() by default; unmatched keys pass through untouched.

Opt out per binding:

new Keymap({ 'a': { preventDefault: false, effect: doThing } })

Or opt out at runtime, for one press only:

new Keymap({ 'a': (ctx) => ctx.permitDefault() })

permitDefault() is not sticky — a later press of the same key prevents the default again.

Layers (push / pop)

Layers stack on top of the base map for modal behaviour. The topmost layer wins; keys it doesn't define fall through to layers below.

const km = new Keymap({ 'j': moveDown, 'k': moveUp })

km.push({ 'j': nudgeSelection }) // overrides only 'j'
km.type('j')                     // nudgeSelection
km.type('k')                     // moveUp — fell through
km.pop()                         // back to the base 'j'

push and pop are LIFO, and push is chainable. pop() on an empty stack is a no-op — it never removes the base, and over-popping never corrupts the stack.

Exclusive layers

A normal push shadows: keys the layer doesn't define fall through to layers below. An exclusive layer doesn't — it hides everything beneath it, so only its own keys resolve. Use it for truly modal states (a command palette, a confirm prompt) where the base bindings should be unreachable.

const km = new Keymap({ 'j': moveDown, 'k': moveUp })

km.push({ 'x': confirm }, { exclusive: true })
km.type('j') // 'unhandled' — base is hidden, no fall-through
km.type('x') // confirm
km.pop()     // base reachable (and merging) again

list() reflects only the exclusive layer while it's active. Exclusive and shadowing layers stack together in LIFO order — an exclusive layer hides everything below it until it's popped.

Mode switching from an effect

An effect can push a layer; it takes effect for the next key:

const km = new Keymap({
  'i': () => km.push({ 'escape': () => km.pop(), 'x': insertX }),
})

km.type('i') // enter "insert mode"
km.type('x') // insertX

Layers and shared prefixes

The topmost layer that has any candidate — complete or partial — for the current buffer owns the resolution. Lower layers can't extend or complete a buffer the top already claims. This keeps sequence resolution decidable without timers.

// Top defines 'g' as complete → it shadows a base 'g g' entirely
const km = new Keymap({ 'g g': goTop })
km.push({ 'g': goLine })
km.type('g') // 'handled' — goLine; base 'g g' is unreachable while pushed

// Top defines 'g g' → it claims the 'g' prefix, shadowing a base 'g'
const km2 = new Keymap({ 'g': goLine })
km2.push({ 'g g': goTop })
km2.type('g') // 'pending' — top owns the prefix, base 'g' stays shadowed
km2.type('g') // 'handled' — goTop

Popping the layer restores the base behaviour exactly.

Introspection

get(key)

Returns the stored binding for a key (or undefined). Object-form bindings keep their group and description:

km.get('a') // { group: 'nav', description: 'do a', effect: … }

list()

Returns one entry per active binding — { keys, group?, description? } — for building help overlays or cheat sheets. It is stack-aware: a pushed layer shadows the base for the same key, so each key appears once, with the active layer's metadata. Re-read it after each push/pop to keep a live which-key panel or cheat sheet in sync with the current mode.

const km = new Keymap({
  'j': { group: 'nav', description: 'down', effect: moveDown },
  'k': { group: 'nav', description: 'up',   effect: moveUp },
})

km.list()
// [
//   { keys: 'j', group: 'nav', description: 'down' },
//   { keys: 'k', group: 'nav', description: 'up' },
// ]

current()

Returns the active resolution map (base merged with pushed layers).

Replacing bindings

load(bindings)

Replaces the entire base map. It also clears any pushed layers and resets the pending buffer, giving you a clean slate. Chainable.

km.load({ 'b': newAction }) // old bindings gone, stack cleared
   .load({ /* … */ })       // returns the keymap

set(key, action)

Overwrites or adds a single binding.

km.set('x', replacement)

Resetting the buffer

reset()

Cancels an in-progress sequence without touching pushed layers. It's detached from this, so you can wire it straight to a blur listener:

input.addEventListener('blur', km.reset)
km.type('g') // 'pending'
km.reset()   // buffer cleared; layers untouched
km.type('g') // a fresh first 'g'

Notes

  • Re-entrant effects are safe: an effect may call type again without corrupting the buffer.
  • The pending buffer stays bounded — sustained unmatched input always resets it, never grows it.
  • Popped layers are released for garbage collection.

API reference

| Member | Returns | Description | |--------|---------|-------------| | new Keymap(bindings?) | Keymap | Create a keymap, optionally seeded with a binding map. | | set(key, action) / set(map) | this | Add or overwrite a single binding, or a whole map. | | type(key, ctx?) | 'handled' \| 'pending' \| 'unhandled' | Dispatch one key (string or KeyboardEvent) and run the match. | | handleKeyboardEvent(event) | void | DOM handler — dispatches and calls preventDefault() for matches. Permanently bound. | | reset() | void | Clear the pending sequence buffer. Permanently bound. | | push(map, options?) | this | Push a layer. { exclusive: true } hides everything below it. | | pop() | this | Remove the top layer (no-op on an empty stack). | | load(bindings) | this | Replace the base map; also clears layers and the buffer. | | get(key) | binding \| undefined | The stored binding for a key. | | list() | { keys, group?, description? }[] | Active bindings, stack-aware — for help overlays. | | current() | Map | The active resolution map (base merged with layers). | | context | UserContext | Property passed to effects dispatched via handleKeyboardEvent. |

Handcrafted

This library is artisanal, hand-written code. Every line of the library source (src/) is 100% human-written — no LLM-generated implementation. LLM assistance was used only to help write the test suite and this README; the design and implementation are entirely human.