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

which-key

v0.3.0

Published

Keyboard-shortcut engine with a which-key popup and cheatsheet. Framework-agnostic core, React binding, and a vanilla-DOM renderer.

Readme

which-key

codecov

A framework-agnostic keyboard-shortcut engine with a leader-key popup and full-screen cheatsheet. Inspired by Emacs/Neovim which-key. Includes a React binding and a vanilla-DOM renderer — pick what fits your stack.

Install

npm i which-key

React peer dependency (already installed in most React projects):

npm i react react-dom

Quick start

React

SSR / client-only: <WhichKeyPopup> and <ShortcutCheatsheet> render nothing during server rendering (Next.js, Remix, etc.) and activate after hydration on the client — they are client-only UI components.

import {
  WhichKeyProvider,
  useShortcut,
  useShortcutGroup,
  WhichKeyPopup,
  ShortcutCheatsheet,
} from 'which-key/react';
import 'which-key/styles.css';

function Editor() {
  useShortcutGroup('g', { description: 'Go to' });
  useShortcut('g h', () => location.assign('/'), { description: 'Home' });
  useShortcut('s', () => save(), { description: 'Save' });
  return <textarea />;
}

export default function App() {
  return (
    <WhichKeyProvider sortKeys="alphabetical">
      <Editor />
      <WhichKeyPopup layout="horizontal" />
      <ShortcutCheatsheet />
    </WhichKeyProvider>
  );
}

Vanilla (no framework)

import { createWhichKey } from 'which-key';
import { mountWhichKey } from 'which-key/vanilla';
import 'which-key/styles.css';

const wk = createWhichKey({ sortKeys: 'alphabetical' });
wk.registerGroup('g', { description: 'Go to' });
wk.register('g h', () => location.assign('/'), { description: 'Home' });
wk.register('s', () => save(), { description: 'Save' });

mountWhichKey(wk, { popup: { layout: 'horizontal' } });
wk.start(); // attach the keydown listener; call wk.stop() to tear down

Note: mountWhichKey subscribes the DOM renderer to the engine but does not call engine.start(). You must call wk.start() yourself so you control when the listener attaches. Call wk.stop() (or unmount()) to tear down.


Concepts

Key sequences

Shortcuts are strings of one or more space-separated keys:

| String | Meaning | | -------- | ------------------------------------------------------- | | s | Single key s | | g h | Leader sequence: press g, then h | | Ctrl+s | Control + s | | Alt+x | Alt (or Option on macOS) + x | | ? | The shifted character itself — write ?, not Shift+/ | | Cmd+k | Command (macOS) + k | | Mod+s | Cmd on macOS, Ctrl elsewhere |

Modifiers are case-insensitive and can be combined: Ctrl+Shift+p.

Groups

A group labels the first key of a sequence so the popup can display a description instead of a bare key. Register a group before (or alongside) any shortcuts under that prefix:

wk.registerGroup('g', { description: 'Go to' });
wk.register('g h', () => navigate('/'), { description: 'Home' });
wk.register('g p', () => navigate('/profile'), { description: 'Profile' });

When the user presses g, the popup shows Go to next to the g row.

The ? cheatsheet

By default, pressing ? opens a full-screen cheatsheet listing every registered shortcut. Disable it by passing helpKey: null:

createWhichKey({ helpKey: null });

Or change the key:

createWhichKey({ helpKey: 'F1' });

In React the same option lives on <WhichKeyProvider helpKey="F1" />.

Sort modes

| Value | Behavior | | ----------------- | ------------------------------------------------ | | 'registration' | (default) Shortcuts appear in registration order | | 'alphabetical' | Sorted A–Z by key string | | custom comparator | (a: string, b: string) => number |


Layers

Layers scope a set of shortcuts to a UI state (e.g. a modal, a panel, a command palette). When you push a layer you get back a LayerHandle whose shortcuts are automatically cleaned up when the layer is popped.

Engine / vanilla

// Open a modal: push an exclusive layer so page shortcuts don't fire while it's open.
const layer = wk.pushLayer({ exclusive: true });

layer.register('Escape', () => closeModal(), { description: 'Close' });
layer.register('j', () => selectNext(), { description: 'Next item' });
layer.register('k', () => selectPrev(), { description: 'Previous item' });

// Close the modal: pop the layer — all its shortcuts are unregistered.
layer.pop();

pushLayer returns a LayerHandle:

| Property / method | Description | | ----------------------------- | --------------------------------------------------------------- | | level | Numeric level assigned to this layer | | register(keys, fn, opts?) | Register a shortcut bound to this layer | | registerGroup(prefix, opts) | Register a group label bound to this layer | | pop() | Unregister all shortcuts on this layer and deactivate the layer |

React

Wrap the modal (or any conditional UI) in <WhichKeyLayer>. Every useShortcut and useShortcutGroup call inside it automatically binds to that layer. The layer activates on mount and deactivates on unmount — no manual cleanup needed.

import {
  WhichKeyProvider,
  WhichKeyLayer,
  useShortcut,
  WhichKeyPopup,
  ShortcutCheatsheet,
} from 'which-key/react';
import { useState } from 'react';

function Modal({ onClose }: { onClose: () => void }) {
  // These shortcuts only fire while the modal is mounted.
  useShortcut('Escape', onClose, { description: 'Close' });
  useShortcut('j', () => console.log('next'), { description: 'Next item' });
  useShortcut('k', () => console.log('prev'), { description: 'Previous item' });
  return (
    <div role="dialog">
      <p>
        Modal open — press <kbd>j</kbd>/<kbd>k</kbd> to navigate, <kbd>Escape</kbd> to close.
      </p>
      <button onClick={onClose}>Close</button>
    </div>
  );
}

export default function App() {
  const [open, setOpen] = useState(false);
  return (
    <WhichKeyProvider>
      <button onClick={() => setOpen(true)}>Open modal</button>
      {open && (
        <WhichKeyLayer exclusive>
          <Modal onClose={() => setOpen(false)} />
        </WhichKeyLayer>
      )}
      <WhichKeyPopup />
      <ShortcutCheatsheet />
    </WhichKeyProvider>
  );
}

Exclusive vs additive layers

pushLayer({ exclusive: true }) (or <WhichKeyLayer exclusive>) blocks all shortcuts registered at lower layers — the page shortcuts s, g h, etc. are silenced while the layer is active. pushLayer({ exclusive: false }) (the default) stacks additively: lower-layer shortcuts continue to fire alongside the new layer's shortcuts. Multiple active layers are resolved in descending level order; the highest active exclusive layer forms a floor below which no lower shortcuts are reachable.

Global shortcuts

A shortcut registered with global: true pierces exclusive layers and fires regardless of which layer is active. The built-in ? help shortcut is global by default — you can open the cheatsheet even when a modal layer is blocking everything else.

// Engine: register a global shortcut
wk.register('Mod+/', () => openHelp(), { description: 'Help', global: true });

// Inside a layer handle (vanilla)
layer.register('x', handler, { global: true });
// React hook
useShortcut('Mod+/', () => openHelp(), { description: 'Help', global: true });

Styling

Import the prebuilt stylesheet:

import 'which-key/styles.css';

Or bring your own by targeting the wk-* CSS class contract. The class contract uses the wk- prefix, or whatever you pass as classPrefix to mountWhichKey (vanilla only) — but the shipped stylesheet (which-key/styles.css) is always written against wk- regardless of classPrefix; see the warning below the custom-prefix example. Most of these carry the default theme's styling and can be overridden to customize appearance; a few (like wk-popup-host) are deliberately unstyled structural hooks the theme leaves alone:

| Class | Element | | ----------------------------- | ---------------------------------------------------------------- | | wk-popup | Popup container | | wk-popup-host | Structural wrapper holding the popup; unstyled; vanilla only | | wk-popup--vertical | Modifier: corner popup layout | | wk-popup--horizontal | Modifier: bottom-bar layout | | wk-popup__header | Header area (vertical layout) | | wk-popup__body | Body area (horizontal layout) | | wk-popup__list | Candidate list (vertical layout) | | wk-popup__grid | Candidate grid (horizontal layout) | | wk-row | Single candidate row | | wk-row--group | Modifier: row represents a group | | wk-row__label | Candidate label text | | wk-kbd | <kbd> key chip | | wk-sequence | Current-sequence display | | wk-sequence__ellipsis | … trailing the current sequence | | wk-backdrop | Full-screen dimmed overlay behind the cheatsheet | | wk-cheatsheet | Cheatsheet panel (scrollable content box) | | wk-cheatsheet__close | Close button in the cheatsheet panel | | wk-cheatsheet__title | Cheatsheet heading | | wk-cheatsheet__sections | Wrapper around all cheatsheet sections | | wk-cheatsheet__section | One group's section | | wk-cheatsheet__list | List of shortcut entries | | wk-cheatsheet__list--nested | Modifier: list nested under a group | | wk-cheatsheet__item | One shortcut entry | | wk-cheatsheet__group-title | Group heading row | | wk-cheatsheet__group-label | Group description text | | wk-cheatsheet__hint | "Press Escape to close" footer |

Custom classPrefix example (vanilla only):

mountWhichKey(wk, { classPrefix: 'myapp' });
// produces: myapp-popup, myapp-kbd, etc.

classPrefix opts you out of which-key/styles.css entirely. The shipped stylesheet hardcodes .wk-* in every selector, so a custom prefix matches none of it — the popup and backdrop lose even their position: fixed, and everything renders inline in the body flow. This also swallows the entire --wk-* custom-property palette described below (including --wk-z-index/--wk-z-index-backdrop), since every one of them is only read inside the same .wk-popup/.wk-backdrop/etc. rules a custom prefix bypasses — none of them offer a partial override. In particular, the popup's background comes entirely from .wk-popup's background declaration now, so a custom prefix gets a fully transparent popup, and backgroundOpacity becomes a complete no-op. If you set classPrefix, supply your own stylesheet covering the whole class table above. Do not import which-key/styles.css alongside it and expect a partial effect; there is none.

classPrefix is vanilla-only. The React components always emit wk-.

Theming

The shipped stylesheet ships dark by default — a consumer who does nothing sees no change. It automatically follows the prefers-color-scheme: light media query, and an author can force either theme regardless of OS preference with data-wk-theme="light" or data-wk-theme="dark" on <html> — specifically the document root; an ancestor further down the tree will not work. The override selectors are :root-scoped, which matches only the actual document root element, so the palette they set is visible wherever the popup or cheatsheet render, including the vanilla renderer's popup host, which is appended to document.body rather than nested under the consumer's markup.

Force light regardless of OS preference:

<html data-wk-theme="light"></html>

Force dark regardless of OS preference:

<html data-wk-theme="dark"></html>

All colours are --wk-* custom properties, so overriding one is the same as overriding any other CSS variable:

:root {
  --wk-focus-ring: #f59e0b;
}

| Property | Dark default (shipped) | Light value | | ---------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | --wk-panel-bg | #111827 | #ffffff | | --wk-panel-bg-rgb | 17, 24, 39 | 255, 255, 255 | | --wk-chip-bg | #374151 | #f3f4f6 | | --wk-border | #374151 | #d1d5db | | --wk-text | #f3f4f6 | #111827 | | --wk-text-muted | #9ca3af | #4b5563 | | --wk-text-subtle | #6b7280 | #6b7280 | | --wk-row-label | #e5e7eb | #1f2937 | | --wk-row-label-group | #93c5fd | #1d4ed8 | | --wk-focus-ring | #93c5fd | #1d4ed8 | | --wk-backdrop-bg | rgba(0, 0, 0, 0.5) | rgba(0, 0, 0, 0.35) | | --wk-shadow-chip | 0 1px 2px rgba(0, 0, 0, 0.05) | 0 1px 2px rgba(0, 0, 0, 0.08) | | --wk-shadow-panel | 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3) | 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.15) |

The popup's backgroundOpacity prop/option (<WhichKeyPopup> and mountWhichKey) sets only the --wk-popup-bg-opacity custom property inline; the colour itself always comes from --wk-panel-bg-rgb in CSS, so the popup repaints correctly under either theme instead of staying pinned to one background.

--wk-z-index/--wk-z-index-backdrop, documented under Stacking order just below, are part of this same --wk-* custom-property surface.

Stacking order

The overlay's z-index is exposed as a CSS custom property so you can place which-key relative to your own modal layer without out-specifying the shipped selectors:

| Property | Default | Applies to | | ----------------------- | ------------------- | ------------------------------------------------- | | --wk-z-index | 1000 | .wk-popup, and .wk-backdrop unless overridden | | --wk-z-index-backdrop | var(--wk-z-index) | .wk-backdrop only |

:root {
  --wk-z-index: 1400; /* above MUI's modal layer (1300) */
}

The default of 1000 clears Ant Design's modal layer but sits below Bootstrap's .modal (1055) and MUI's modal (1300) — raise it if you need the cheatsheet over one of those.


Troubleshooting

which-key's failure mode is almost always "nothing happens". Work down this list.

1. Is <WhichKeyProvider> an ancestor, and did you call engine.start()? The React hooks and both renderer components warn on the console when they are used outside the provider. The engine does not attach its keydown listener until start() is called — mountWhichKey deliberately does not call it for you.

2. Is focus in a text field? Shortcuts are suppressed while focus is in an <input>, <textarea> or contenteditable element unless you register them with enableOnInputs: true.

3. Does your key string canonicalize to what the browser actually reports? This is the most common silent failure. Registration and runtime both funnel through the same canonicalizer and the registry looks up plain strings, so a mismatch means the lookup simply misses:

import { parseKey, eventToCanonical } from 'which-key';

document.addEventListener('keydown', (e) => {
  console.log('pressed:', eventToCanonical(e), 'registered:', parseKey('g'));
});

If those two differ for the same physical keypress, that is your bug. Watch for: letters uppercase under any modifier (parseKey('ctrl+k') → 'Ctrl+K'); Shift+ dropped for punctuation and digits, which silently rebinds to the unshifted character (parseKey('Shift+/') → '/', not '?' — write register('?', ...) directly; the library warns when it detects this); and special key names — 'escape', 'esc', 'up' and 'f1' are all accepted case-insensitively and round-trip correctly against the real event ('Escape', 'ArrowUp', 'F1'). A base that isn't one of these recognised names or aliases still registers, but now warns that the binding may never match; if the browser genuinely reports that exact spelling (an exotic event.key like 'MediaPlayPause'), the warning is a false positive you can ignore, but a typo is far more likely.

4. Is an exclusive layer active? An exclusive layer makes every shortcut below its level unreachable. Register with global: true to punch through, or check what is live with engine.registry.getAllActive().

5. Is another registration winning? Multiple components may bind the same key. The winner is decided by level, then priority, then most-recent registration. A same-level collision emits a console warning naming both entries; raise the loser's priority or unregister one.

Console warnings

which-key writes diagnostics to the console prefixed with [whichkey]. Nearly all are console.warn advisories for consumer misuse; two are console.error, logged when your own shortcut handler throws or a subscribe listener throws. None of them propagates an exception into your app. See the warning reference for what each one means.


API

See docs/API.md for the full reference.


Coverage

The test suite is gated at 80% (lines, statements, functions, branches). npm test runs Vitest with V8 coverage and prints a report; CI uploads results to Codecov.


Contributing

See CONTRIBUTING.md.


Releasing

Versioning is driven by Conventional Commits. The version bump and changelog are derived from commit messages since the last tag. While the package is pre-1.0 (0.x), semver-for-0.x rules apply:

  • fix: … → patch (e.g. 0.2.0 → 0.2.1)
  • feat: … → patch while 0.x (the public API is still unstable)
  • feat!: … or a BREAKING CHANGE: footer → minor while 0.x (0.2.0 → 0.3.0)

(After the project cuts 1.0.0, the usual rules resume: feat:→minor, feat!:→major.)

Commit messages are linted by a husky commit-msg hook (@commitlint/config-conventional).

First release (one-time): the default first bump with no prior tag would be a patch (0.1.1); to start at 0.2.0 instead, force a minor once:

npm run release -- --release-as minor   # 0.1.0 → 0.2.0, writes CHANGELOG.md, tags v0.2.0

Ongoing releases:

npm run release:dry   # preview the next version + changelog (no changes)
npm run release       # bump package.json, regenerate CHANGELOG.md, commit, and tag

npm run release does not push or publish. After it succeeds, publish manually:

git push --follow-tags origin main
npm publish           # prepublishOnly rebuilds dist/ first

To intentionally cut 1.0.0: npm run release -- --release-as major.


License

MIT © Steven Carter