DEV Community

Cover image for I built a tiny, zero-dependency React hook for keyboard shortcuts
Dhruvil Shah
Dhruvil Shah

Posted on • Originally published at npmjs.com

I built a tiny, zero-dependency React hook for keyboard shortcuts

Most apps eventually need keyboard shortcuts - a command palette on Cmd/Ctrl+K, Esc to close a modal, mod+S to save. I kept rewriting the same keydown boilerplate, so I packaged it into a tiny hook.

@dhruvilshah191999/use-shortcut - zero dependencies, ~1KB gzipped, TypeScript-first.

🔗 Live demo: https://use-shortcut-playground.vercel.app/

📦 npm: https://www.npmjs.com/package/@dhruvilshah191999/use-shortcut
💻 Source: https://github.com/dhruvilshah191999/use-shortcut

Install

npm install @dhruvilshah191999/use-shortcut
Enter fullscreen mode Exit fullscreen mode

The basics

import { useShortcut } from "@dhruvilshah191999/use-shortcut";

function SearchButton() {
  const [open, setOpen] = useState(false);

  useShortcut("mod+k", () => setOpen(true));
  // ...
}
Enter fullscreen mode Exit fullscreen mode

That's the whole API for simple cases: a combo string, a callback, done.

What makes it nice to use

mod is cross-platform out of the box

useShortcut("mod+s", save, { preventDefault: true }); // ⌘S on Mac, Ctrl+S elsewhere
Enter fullscreen mode Exit fullscreen mode

Combos are case-insensitive and whitespace-tolerant ("Cmd+K", "cmd+k", " cmd + k " all work), with aliases like cmd↔command, ctrl↔control, alt↔option.

Smart input handling

By default, shortcuts don't fire while you're typing in an <input>, <textarea>, <select>, or contenteditable - except Escape, which always fires (closing a modal should work even with focus in a field). You can opt back in with enableInInputs: true.

Always-fresh callback (no useCallback needed)

The hook always calls the latest callback via a ref, so you never get a stale closure:

function Counter() {
  const [count, setCount] = useState(0);
  // logs the CURRENT count every time, no useCallback required
  useShortcut("c", () => console.log(count));
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Optional scopes (when you outgrow "global")

Every shortcut is global by default. When you need the same key to mean different things in different parts of the UI, wrap a subtree in a provider and gate shortcuts by scope:

import {
  ShortcutProvider,
  useShortcut,
  useShortcutScopes,
} from "@dhruvilshah191999/use-shortcut";

function App() {
  return (
    <ShortcutProvider initialScopes={["list"]}>
      <List />
      <Modal />
    </ShortcutProvider>
  );
}

function Modal() {
  const { activate, deactivate } = useShortcutScopes();
  useShortcut("escape", close, { scope: "modal" }); // fires only when "modal" is active
  // open(): activate("modal"); deactivate("list");
}
Enter fullscreen mode Exit fullscreen mode

Scopes are fully opt-in - if you never use a scope, nothing changes and you don't need the provider.

The whole API

useShortcut(
  key: string | string[],
  callback: (e: KeyboardEvent) => void,
  options?: {
    enabled?: boolean;          // default: true
    preventDefault?: boolean;   // default: false
    stopPropagation?: boolean;  // default: false
    enableInInputs?: boolean;   // default: false
    target?: RefObject<HTMLElement> | Window; // default: window
    scope?: string | string[];  // optional, opt-in
  }
): void
Enter fullscreen mode Exit fullscreen mode

Try it

The live demo exercises every feature with an on-screen event log — open it and start pressing keys.

If you find it useful, a ⭐ on the repo is appreciated. Feedback and issues welcome!

Top comments (0)