# Keyboard shortcuts that behave like good coworkers

Try the [interactive keyboard-shortcut demo](https://use-a-hook.pages.dev/#shortcuts): focus the workspace, press G then I, and compare that with typing inside its protected input. The [live useHotkeys reference](https://use-a-hook.pages.dev/#api-useHotkeys) includes the full example and commented signature.

`useHotkeys` handles keyboard shortcuts without adding a runtime dependency. Existing single-chord calls still work. You can now use alternatives, timed sequences, focus scopes, named application groups, keyboard-release callbacks, and separate controls for editable fields.

## The command concierge

A quick-search button should answer Ctrl/Command-K without accidentally hijacking someone's invoice notes. Start with the [complete quick-search demo](examples/useHotkeys.tsx).

```tsx
import { useHotkeys } from 'use-a-hook';

useHotkeys(['mod+k', 'mod+/'], () => setSearchOpen(true), {
  preventDefault: true,
});
useHotkeys('esc', () => setSearchOpen(false), {
  enabled: searchOpen,
  enableOnFormTags: ['input'],
});
```

Here `mod` accepts either Ctrl or Meta/Command on every platform, preserving our original API. The callback always sees the latest committed props/state. No dependency array is needed.

## Two editors, one shortcut, zero sibling rivalry

The returned callback ref limits a shortcut to the focused element and its descendants. Give a container `tabIndex={0}` if users need to focus it directly. Form fields still require an explicit opt-in.

```tsx
const editorRef = useHotkeys<HTMLTextAreaElement>('mod+s', saveDraft, {
  enableOnFormTags: ['textarea'],
  preventDefault: true,
});

return <textarea ref={editorRef} aria-label="Draft" />;
```

[Run the complete two-editor demo](examples/hotkeys/ScopedEditor.tsx). Its save messages are local previews, not server writes.

Ignore the returned ref for global handling. Once the ref has been attached, detaching it disables that binding instead of unexpectedly making it global. The ref takes priority over `options.target`. Attach a binding's ref to one element at a time; use another hook call for another independent scope.

## Inbox express: G, then I

```tsx
useHotkeys('g>i, mod+k>mod+s', (_event, match) => {
  if (match.hotkey === 'g>i') openInbox();
  else saveWorkspace();
}, { sequenceTimeoutMs: 1000 });
```

The timeout is the maximum gap between steps. Sequences support modifiers and overlapping prefixes, such as `a>a>b` within `a>a>a>b`. Incorrect keys, ignored events, composition, focus transitions, and window blur reset partial sequences. Modifier-only events between steps do not interrupt a sequence unless they are themselves the expected step.

Progress is tracked independently for each alternative and for keydown/keyup. No timers are scheduled: expiry is checked at the next event. If several alternatives complete on one event, the callback runs once, with metadata for the first completed alternative. `preventDefault` applies only to the completed, accepted shortcut, never to unfinished sequence prefixes.

## Named workspaces, your own state

```tsx
useHotkeys('mod+s', saveDraft, {
  scopes: 'editor',
  activeScopes, // For example ['editor'], from your state or context.
  preventDefault: true,
});
```

[Open the complete workspace-switching demo](examples/hotkeys/GroupedWorkspace.tsx).

Omitting `scopes`, or using `'*'`, makes a binding global. `activeScopes` defaults to `['*']`, which enables every group. Supply `[]` to disable all named groups. Groups can coexist with a focus ref; both conditions must pass. The package does not install a provider or own your application's active groups.

## Characters versus keyboard positions

By default, `useKey: true` matches `KeyboardEvent.key`, the character your keyboard layout produces. Use `useKey: false` for physical `KeyboardEvent.code` positions, such as `KeyW` or `Digit1`. Physical matching falls back to `event.key` only if the browser supplies no code.

[Try the keyboard-layout demo](examples/hotkeys/KeyboardLayouts.tsx).

Aliases include `esc`, `return`, `space`, `left`, `right`, `up`, `down`, `control`, `cmd`, `command`, `option`, `del`, `plus`, and `comma`. Use `plus` for the `+` key and `comma` for the `,` key. You can customize `delimiter` (alternatives), `splitKey` (chords), and `sequenceSplitKey` (sequences). Separators must be nonempty. Each chord contains modifiers plus one non-modifier key; arbitrary simultaneous letter-key combinations are not supported.

## Opt into browser interception carefully

```tsx
useHotkeys('mod+s', saveDraft, {
  enabled: () => canSave,
  preventDefault: (_event, match) => match.hotkey === 'mod+s',
  ignoreEventWhen: event => event.defaultPrevented,
});
```

- `enabled: false` removes listeners. An enabled predicate rejects individual completed matches.
- Rejected, filtered, out-of-scope, or editable-field events keep their browser default action.
- `enableOnFormTags: true` allows form fields and protected editable ARIA roles; an array opts into selected tags/roles. `enableOnContentEditable` is independent.
- `allowInInput: true` remains the original override for all editable fields, including contenteditable.
- Composition/IME events are always ignored. Held-key repeats are ignored unless `repeat: true`.
- `keyup: true` by itself selects release-only handling. Set both `keydown: true` and `keyup: true` to run on both events.
- `capture: true` listens during capture. `target` supports an element, document, window, or other EventTarget; `null` disables listening.
- Browser-reserved shortcuts may not be overridable. If a child stops propagation, a bubble-phase ancestor binding cannot receive the event.
- Scoped focus and editable checks support iframe owner documents and open shadow roots. Closed shadow roots hide their internals from outside listeners; attach inside that root if you need its internal focus/editable information.

## Performance contract

Bindings compile when shortcut strings/separators change. Equivalent inline arrays keep the same compiled bindings; changing callback identity or description does not replace listeners. Changes to matching configuration reset sequence progress and rebind listeners. A focus ref can cause a setup render when its element attaches or detaches; keyboard matching itself causes no React renders. Your callback can still update state normally.

There are no global pressed-key listeners, timers, polling loops, or frame callbacks at import time. Listener setup and cleanup are symmetric, including Strict Mode. Bundle checks cover both root and individual imports and enforce an independent gzip budget for this hook.

## Study notes and compatibility

This is an independent implementation informed by [react-hotkeys-hook](https://github.com/JohannesKlauss/react-hotkeys-hook), its source/tests, and its MIT license. The study checked npm version 5.3.3 and upstream source commit `57b26dc62462fe814c80dc7a50fdb49d7df1ad78` (whose package metadata says 5.3.2). No upstream source was copied and no runtime dependency was added.

This is not a drop-in replacement. We preserve `use-a-hook` defaults: character matching, Ctrl-or-Meta `mod`, and ignored repeats. Callbacks are refreshed after commit without a dependencies argument. Focus scoping returns a callback ref. Named groups use `activeScopes` from your application, rather than an upstream `HotkeysProvider`. Enabled predicates do not cancel rejected events. Recording shortcuts, global pressed-key inspection, arbitrary multi-letter simultaneous chords, and upstream dependency-array overloads are outside this API.

See the [commented API signature](API.md#usehotkeys) for every option and return type.
