# useHotkeys

A hook that binds keyboard shortcuts with modifier and sequence support.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/use-hotkeys.json
```

[Registry JSON](https://mwui.vercel.app/r/use-hotkeys.json)

## Preview

```tsx
import * as React from "react";

import { useHotkeys } from "@/hooks/use-hotkeys";

function Kbd({ children }: { children: React.ReactNode }) {
  return (
    <kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.7rem]">
      {children}
    </kbd>
  );
}

export function Preview() {
  const [log, setLog] = React.useState<{ id: number; label: string }[]>([]);

  const push = React.useCallback((label: string) => {
    setLog((previous) => [{ id: Date.now(), label }, ...previous].slice(0, 4));
  }, []);

  useHotkeys("mod+k", () => push("mod+k — open command menu"));
  useHotkeys("g i", () => push("g i — go to inbox"));
  useHotkeys("?", () => push("? — show shortcuts"));

  return (
    <div className="flex w-full max-w-sm flex-col gap-3 text-sm">
      <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
        Try <Kbd>⌘/Ctrl</Kbd>
        <Kbd>K</Kbd>, then <Kbd>G</Kbd> <Kbd>I</Kbd>, then <Kbd>?</Kbd>
      </div>

      <div className="min-h-24 rounded-lg border border-border p-2">
        {log.length === 0 ? (
          <p className="p-1 text-xs text-muted-foreground">No shortcut fired yet.</p>
        ) : (
          <ul className="flex flex-col gap-1">
            {log.map((entry, index) => (
              <li
                key={entry.id}
                className="rounded px-1 py-0.5 font-mono text-xs data-[latest=true]:bg-muted"
                data-latest={index === 0}
              >
                {entry.label}
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}
```


## Source

### hooks/use-hotkeys.ts

```ts
"use client";

import * as React from "react";

type HotkeyStep = {
  key: string;
  ctrl: boolean;
  meta: boolean;
  alt: boolean;
  shift: boolean;
};

type UseHotkeysOptions = {
  enabled?: boolean;
  /** Element the listener is attached to. Defaults to `document`. */
  target?: HTMLElement | Document | null;
  /** Fire while an input, textarea, select, or contenteditable has focus. */
  enableOnFormElements?: boolean;
  preventDefault?: boolean;
  /** Milliseconds allowed between the steps of a sequence such as `"g i"`. */
  sequenceTimeout?: number;
};

const KEY_ALIASES: Record<string, string> = {
  esc: "escape",
  del: "delete",
  ins: "insert",
  up: "arrowup",
  down: "arrowdown",
  left: "arrowleft",
  right: "arrowright",
  spacebar: "space",
  return: "enter",
  cmd: "meta",
  command: "meta",
  control: "ctrl",
  option: "alt",
};

const MODIFIERS = new Set(["ctrl", "meta", "alt", "shift", "mod"]);

function useHotkeys(
  hotkeys: string | string[],
  handler: (event: KeyboardEvent) => void,
  options: UseHotkeysOptions = {},
): void {
  const {
    enabled = true,
    target,
    enableOnFormElements = false,
    preventDefault = true,
    sequenceTimeout = 1000,
  } = options;

  const bindingKey = Array.isArray(hotkeys) ? hotkeys.join("|") : hotkeys;
  const handlerRef = React.useRef(handler);

  React.useEffect(() => {
    handlerRef.current = handler;
  });

  React.useEffect(() => {
    if (!enabled) return undefined;

    const element = target ?? document;
    const bindings = bindingKey.split("|").map(parseHotkey);
    const longest = Math.max(...bindings.map((binding) => binding.length));

    let buffer: KeyboardEvent[] = [];
    let timeout: ReturnType<typeof setTimeout> | undefined;

    const onKeyDown = (event: Event) => {
      if (!(event instanceof KeyboardEvent)) return;

      const keyboardEvent = event;

      if (keyboardEvent.repeat) return;
      if (isModifierKey(keyboardEvent.key)) return;
      if (!enableOnFormElements && isFormElement(keyboardEvent.target)) return;

      buffer = [...buffer, keyboardEvent].slice(-longest);

      clearTimeout(timeout);
      timeout = setTimeout(() => {
        buffer = [];
      }, sequenceTimeout);

      for (const binding of bindings) {
        if (binding.length > buffer.length) continue;

        const tail = buffer.slice(-binding.length);
        const matched = binding.every((step, index) => {
          const stepEvent = tail[index];
          return stepEvent ? matchesStep(step, stepEvent) : false;
        });

        if (!matched) continue;

        buffer = [];
        clearTimeout(timeout);
        if (preventDefault) keyboardEvent.preventDefault();
        handlerRef.current(keyboardEvent);
        return;
      }
    };

    element.addEventListener("keydown", onKeyDown);

    return () => {
      clearTimeout(timeout);
      element.removeEventListener("keydown", onKeyDown);
    };
  }, [bindingKey, enabled, target, enableOnFormElements, preventDefault, sequenceTimeout]);
}

function parseHotkey(hotkey: string): HotkeyStep[] {
  return hotkey
    .trim()
    .split(/\s+/u)
    .filter(Boolean)
    .map((step) => parseHotkeyStep(step));
}

function parseHotkeyStep(step: string): HotkeyStep {
  const parts = step
    .split("+")
    .map((part) => normalizeKey(part))
    .filter(Boolean);

  const isApple = typeof navigator !== "undefined" && /mac|iphone|ipad/iu.test(navigator.userAgent);
  const key = parts.find((part) => !MODIFIERS.has(part)) ?? "";
  const has = (name: string) => parts.includes(name);
  const mod = has("mod");

  return {
    key,
    ctrl: has("ctrl") || (mod && !isApple),
    meta: has("meta") || (mod && isApple),
    alt: has("alt"),
    shift: has("shift"),
  };
}

function normalizeKey(key: string): string {
  const normalized = key.trim().toLowerCase();

  if (normalized === " ") return "space";
  return KEY_ALIASES[normalized] ?? normalized;
}

function matchesStep(step: HotkeyStep, event: KeyboardEvent): boolean {
  if (step.ctrl !== event.ctrlKey) return false;
  if (step.meta !== event.metaKey) return false;
  if (step.alt !== event.altKey) return false;
  if (step.shift && !event.shiftKey) return false;

  // A literal `"k"` should not fire on `Shift+K`, but a symbol such as `"?"` needs Shift to type.
  if (!step.shift && event.shiftKey && /^[a-z0-9]$/u.test(step.key)) return false;

  return normalizeKey(event.key) === step.key || codeToKey(event.code) === step.key;
}

function codeToKey(code: string): string {
  if (code.startsWith("Key")) return code.slice(3).toLowerCase();
  if (code.startsWith("Digit")) return code.slice(5);

  const punctuation: Record<string, string> = {
    Slash: "/",
    Period: ".",
    Comma: ",",
    Semicolon: ";",
    Quote: "'",
    Backquote: "`",
    Minus: "-",
    Equal: "=",
    BracketLeft: "[",
    BracketRight: "]",
    Backslash: "\\",
  };

  return punctuation[code] ?? code.toLowerCase();
}

function isModifierKey(key: string): boolean {
  return ["Control", "Meta", "Alt", "Shift"].includes(key);
}

function isFormElement(target: EventTarget | null): boolean {
  if (!(target instanceof HTMLElement)) return false;
  if (target.isContentEditable) return true;

  return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}

export { useHotkeys, type UseHotkeysOptions };
```



## Usage

Declare a shortcut where the thing it does lives, instead of routing every key through one global
`keydown` switch.

```tsx
import { useHotkeys } from "@/hooks/use-hotkeys";

function CommandMenu() {
  const [open, setOpen] = React.useState(false);

  useHotkeys("mod+k", () => setOpen((value) => !value));

  return <CommandDialog open={open} onOpenChange={setOpen} />;
}
```

## Syntax

- **Combinations** join with `+`: `"mod+k"`, `"ctrl+shift+p"`, `"alt+arrowleft"`.
- **Sequences** separate with a space: `"g i"` fires when `g` is followed by `i` within
  `sequenceTimeout`. This is the Gmail-style navigation pattern.
- **`mod`** is `⌘` on Apple platforms and `Ctrl` everywhere else — the whole reason not to hardcode
  `meta`.
- Aliases are accepted for the names people actually type: `esc`, `del`, `up`, `down`, `left`,
  `right`, `return`, `cmd`, `option`, `space`.

Pass an array to bind several shortcuts to the same handler:

```tsx
useHotkeys(["mod+s", "ctrl+s"], save);
```

## Matching

Modifiers must match exactly, so `"k"` does not fire on `⌘K`. Keys are matched against both
`event.key` and a normalized `event.code`, which means two things worth knowing:

- Letter shortcuts work on non-QWERTY layouts, because `KeyK` resolves to `k` regardless of layout.
- Shifted punctuation works either way: `"?"` and `"shift+/"` both fire on the same keystroke.

## Options

| Option                 | Description                                                                 |
| ---------------------- | --------------------------------------------------------------------------- |
| `enabled`              | Detach the listener without unmounting — e.g. only while a panel is focused. |
| `target`               | Element to listen on. Defaults to `document`.                                |
| `enableOnFormElements` | Fire while an input, textarea, select, or contenteditable has focus. Off by default. |
| `preventDefault`       | Call `preventDefault` on a match. On by default, since most shortcuts shadow a browser default. |
| `sequenceTimeout`      | Milliseconds allowed between sequence steps. Defaults to `1000`.             |

## Behavior

- The handler is read from a ref, so it always sees current state and never re-binds the listener.
- Auto-repeat is ignored, so holding a key fires once.
- Pressing a modifier on its own never starts or breaks a sequence.
- Sequence progress resets after a match, a timeout, or a non-matching key.

