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

keyboardist

v3.0.0

Published

A declarative way to add keyboard shortcuts to your browser applications — with React hooks and components at keyboardist/react

Readme

🎹 Keyboardist: Declarative keyboard listener

A declarative way to add keyboard shortcuts to your browser applications, with zero dependencies.

Using React? Hooks and components ship in the same package — see keyboardist/react.

import { createListener } from "keyboardist";

// by default it listens to keydown
const listener = createListener();

listener.subscribe("Down", () => {
  console.log("Pressed down");
});

listener.subscribe("Shift+Down", () => {
  console.log("Pressed Shift + down");
});

Install

npm install keyboardist

Keyboardist is published as an ES module.

Why not just addEventListener?

For one shortcut, you don't need a library:

document.addEventListener("keydown", (e) => {
  if (e.key === "k" && e.metaKey) openPalette();
});

That's fine — until you add the second shortcut, and the third, and a modal. Keyboardist exists because the code above quietly grows five hard problems, and every app ends up hand-rolling the same solutions:

  • Key naming. event.key vs event.code, layout quirks, modifier combinations, and the if (e.shiftKey && !e.metaKey && ...) chains that come with them. Keyboardist gives every combination one canonical, writable name — "shift+up", "cmd+k" — and matching is just a map lookup.
  • Typing vs shortcuts. Raw listeners fire while the user types into an input, a textarea, or a contenteditable editor. Everyone discovers this in production. Keyboardist ignores editable targets by default (and lets you attach to an input deliberately when that's what you want).
  • preventDefault discipline. Swallow too much and you break the browser; too little and the page scrolls when Space was your play button. Keyboardist prevents default only when a binding actually matched.
  • Modes and modals. The genuinely hard one. The moment a modal, command palette, or "mode" needs its own keys, you're building a priority system: who wins, what's disabled, and how everything is restored when it closes — including when two modals overlap and close out of order. That's layers, and it's the reason this library exists: the ad-hoc version of this (save the old handler, restore it in a closure) is exactly where hand-rolled implementations grow bugs.
  • Lifecycle. Subscriptions that clean up after themselves (unsubscribe, using), multiple handlers per key with predictable order and stop-propagation, and a monitor so you can see what the keyboard is doing instead of sprinkling console.log into a raw handler.

All of that for ~3 kB gzipped, zero dependencies, one addEventListener per listener under the hood, and false instead of a crash on the server. If your app has one shortcut, keep the raw listener. The day it has three and a modal, this is the code you were going to write anyway — already tested.

Usage

createListener returns a listener object. In non-browser environments (e.g. during server-side rendering) it returns false instead, so check the return value if your code also runs outside the browser.

subscribe accepts two arguments: a key or key combination and a callback that will run when that key (or key combination) is pressed. The callback receives the original KeyboardEvent.

import { createListener } from "keyboardist";

const listener = createListener();

const keySubscription = listener.subscribe("Slash", () => {
  focusSearch();
});

Key names

Friendly names are canonical, and raw event.code spellings normalize to the same key — all of these match the same binding:

| You write | Canonical name | | --- | --- | | a, KeyA, keya | a | | up, ArrowUp | up | | 1, Digit1 | 1 (numpad1 stays distinct) | | shift+up, Shift + ArrowUp | shift+up | | cmd+k, Meta+K, command+k | meta+k | | ctrl+shift+p, shift+control+p | shift+ctrl+p |

Case and spaces are ignored; modifiers always normalize to the order alt, shift, ctrl, meta. A comma binds one handler to several keys: subscribe("j,k", fn) fires for both. If you're unsure of a key's name, use the monitor. The normalizer is exported as normalizeKeyName() if you need it.

The object returned by subscribe has an unsubscribe method:

// create a subscription
const keySubscription = listener.subscribe("Slash", () => {
  focusSearch();
});

// remove the subscription
keySubscription.unsubscribe();

Multiple listeners for a key

You can add multiple listeners for the same key. They run starting from the last one subscribed, and returning false from a callback stops the earlier ones from running.

listener.subscribe("Space", () => {
  console.log("A");
});

listener.subscribe("Space", () => {
  console.log("B");
});

listener.subscribe("Space", () => {
  console.log("C");
});

// the console will log 'C', then 'B', then 'A' when the spacebar is pressed.

Describing bindings

Bindings can carry a description, so your app can build its own shortcut sheet from the bindings that are actually live — no second list to keep in sync:

listener.subscribe(
  "Down",
  () => {
    moveDown();
  },
  { description: "Moves down one item" },
);

// a bare string is shorthand for { description }
listener.subscribe("Up", moveUp, "Moves up one item");

Read them back with getBindings():

listener.getBindings();
// [
//   { layer: "base", key: "down", active: true, priority: 0,
//     description: "Moves down one item" },
//   { layer: "base", key: "up",   active: true, priority: 0,
//     description: "Moves up one item" },
// ]

In a bindings map, swap the callback for an object to describe it. Bare callbacks keep working, and the two forms mix freely:

listener.layer("editor", {
  KeyS: { handler: save, description: "Saves the document" },
  KeyZ: undo, // undocumented, still bound
});

Bindings listed by getBindings() are grouped by layer, not flattened by key — the same key can be bound on several layers at once, which is the whole point of layers. Use active to show only what's reachable right now:

const live = listener.getBindings().filter((binding) => binding.active);

Pass hidden: true for bindings you don't want on the sheet. They stay fully functional — this is for plumbing, like the shortcut that opens the sheet itself:

listener.subscribe("shift+slash", toggleHelp, { hidden: true });

Note: key names come from event.code, so ? is "shift+slash" on a US layout. Subscribing to the literal "?" will never match. On layouts where ? isn't shift+slash, pick the binding that suits your users.

When a key has several subscriptions on one layer, the last one subscribed wins per field — it's also the first to run, so it's the one the user gets. Unsubscribing it falls back to the description beneath.

Layers

Bindings can live on named layers that stack. The topmost layer with a binding for a key wins and shadows the layers below it; keys that don't match fall through. This is how you give a modal its own keyboard without tearing down the rest of the app:

const kb = createListener();

// base bindings — always at the bottom of the stack
kb.subscribe("slash", focusSearch);

// a named layer with map registration; commas bind aliases
const player = kb.layer("player", {
  space: togglePlay,
  "j,k": step,
  "shift+up": volumeUp,
});
player.push(); // player bindings are now live

// an exclusive layer: unmatched keys go inert instead of falling through,
// so every player shortcut is disabled while the modal is open
const modal = kb.layer("modal", { escape: closeModal }, { exclusive: true });

const pop = modal.push(); // modal now owns the keyboard
// ...when the modal closes:
pop(); // player (and base) bindings are live again

Popping is order-independent: if two modals overlap, popping the lower one leaves the upper one exactly where it is. Re-pushing an active layer moves it to the top. Layers also have subscribe(key, fn), bind(map) (returns one subscription for the whole map), pop(), isActive(), and dispose().

Priority

When push order can't express who should win — for example when a framework schedules your pushes in an order you don't control — give layers a priority (default 0). Higher-priority layers always sit above lower-priority ones regardless of push order; within the same priority, the latest push is on top, exactly like before:

kb.layer("layout", { escape: closeSidebar }, { priority: 1 });
kb.layer("modal", { escape: closeModal }, { priority: 3 });

// no matter which order these get pushed in, modal beats layout

(If you use keyboardist/react, priority is derived from the component tree automatically — you shouldn't need to set it by hand.)

Inspect the stack at runtime with kb.activeLayers() (names, top to bottom, ending in "base") and kb.getBindings() (every binding with its layer, active state, and description).

Key monitor

The listener has a setMonitor method that lets you set a function that will observe every key event. Pass true to use the default built-in monitor (which logs to the console) or pass your own function. Pass false (or nothing) to clear it.

The default monitor is useful in development when you don't know the correct key name you want to use.

const listener = createListener();

// use the default monitor
listener.setMonitor(true);

// will show the key names / combination as you type them. For example:
// `:keyboard event: a`
// `:keyboard event: slash`
// `:keyboard event: shift+space`

A custom monitor receives a single object: keyName (canonical name), matched (true if a binding won), layer (the winning layer's name, or null), and the original event.

listener.setMonitor(({ keyName, matched, layer, event }) => {
  document.getElementById("monitor").innerHTML = `You pressed ${keyName}`;
});

Other events

By default the listener listens to keydown events, but you can pass keyup to use that event instead:

import { createListener } from "keyboardist";

const downListener = createListener();
const upListener = createListener("keyup");

downListener.subscribe("KeyA", () => {
  console.log("Just pressed the A key");
});

upListener.subscribe("KeyA", () => {
  console.log("Just released the A key");
});

Listening on a specific element

createListener accepts an element as a second argument. By default it listens on document. Keyboard events originating from form elements (input, textarea, select, button) are ignored — unless the listener is attached to that element directly:

const input = document.getElementById("search");
const inputListener = createListener("keydown", input);

inputListener.subscribe("Escape", () => input.blur());

Stop listening

Internally each listener attaches a single event handler to the target element. stopListening removes it; startListening re-attaches it.

const listener = createListener();

listener.subscribe("KeyA", () => {
  console.log("Just pressed the A key");
});

// Remove the event listener from the document
listener.stopListening();

// Reattach it again:
listener.startListening();

using support

Subscriptions and layer push-handles implement [Symbol.dispose], so they work with explicit resource management:

{
  using sub = kb.subscribe("slash", focusSearch);
  using modalSession = modal.push();
  // ...
} // automatically unsubscribed and popped here

React

React hooks and components ship as a subpath of this same package — one install, one version, RSC-safe:

import Keyboardist, { KeyboardLayer, useKeyBindings } from "keyboardist/react";

// works directly inside a Next.js server component tree — the subpath
// ships its own "use client" boundary
export default function Page() {
  return (
    <>
      <Keyboardist bindings={{ "cmd+k": openPalette, slash: focusSearch }} />
      <Content />
    </>
  );
}

Requires React 18 or 19 (an optional peer dependency — vanilla users see no peer warnings).

Hooks

  • useKeyBindings(map) — global bindings for the component's lifetime. Inline objects are fine: callbacks are read through refs, and resubscription only happens when the keys or their descriptions change.
  • useKeyboardLayer(map, { exclusive, active, priority, name }) — a layer scoped to the component: created on mount, pushed while active (default true), disposed on unmount. Returns a handle with isActive(), push(), pop().
  • useKeyMonitor(fn) — observes every key event with the structured monitor payload (one monitor slot per listener; last mounted wins).
  • useElementKeyBindings(ref, map) — a dedicated listener attached to an element; bindings keep firing while the user types in it.

Components

// global bindings, renders nothing (the classic react-keyboardist API)
<Keyboardist bindings={{ slash: focusSearch }} monitor={logKeys} />

// scope the keyboard to a subtree while it's mounted
<KeyboardLayer bindings={{ escape: close }} exclusive>
  <Modal />
</KeyboardLayer>

// an input with its own attached listener
<KeyboardInput bindings={{ up: increment, down: decrement }} ref={inputRef} />

Any bindings prop takes the described form, so a component can document its own shortcuts:

<KeyboardLayer
  bindings={{
    escape: { handler: close, description: "Closes the dialog" },
    enter: { handler: submit, description: "Saves and closes" },
  }}
  exclusive
>
  <Modal />
</KeyboardLayer>

Because a layer knows which of its bindings are active, a help sheet can be rendered straight off getSharedListener().getBindings() — it updates itself as layers push and pop.

Nesting is priority

When layers overlap on the same key, the innermost component wins — the JSX nesting is the priority:

<DashboardLayout>            {/* KeyboardLayer: escape → close sidebar */}
  <Posts>                    {/* KeyboardLayer: escape → clear selection */}
    <EditPostModal />        {/* KeyboardLayer: escape → close modal ← wins */}
  </Posts>
</DashboardLayout>

This holds even when the whole tree mounts in a single commit (React runs effects child-first, so without this the outermost layer would land on top of the stack). Priority is derived from <KeyboardLayer> nesting via context, so it flows through portals too: a createPortal modal keeps the priority of its place in the JSX tree, not the DOM. Hook-only users can add a nesting level with <KeyboardScope>, and an explicit priority prop/option overrides the derived depth.

Server-side rendering & React Server Components

  • The keyboardist/react bundle starts with 'use client', so importing any component from a server component automatically creates the client boundary.
  • Listeners are created lazily on first client-side effect — importing the package never touches window, and rendering on the server is a no-op.

TypeScript

Keyboardist ships its own type definitions:

import {
  createListener,
  type BindingEntry,
  type BindingInfo,
  type BindingOptions,
  type KeyboardistListener,
  type Layer,
  type MonitorInfo,
  type Subscription,
} from "keyboardist";

BindingMap values are SubscriptionCallback | BindingEntry, so both the bare-callback and described forms typecheck. One wrinkle worth knowing in tests: a bare vi.fn() no longer infers against the union — give it an implementation (vi.fn(() => {})) and it resolves.

License

MIT © Armando Sosa