useHotkeys
StableSingle-key and modifier shortcuts, Linear style, that wait while you type and can be scoped to one part of the page.
useHotkeys binds a map of combos to handlers: { c: createTask, "mod+k": openSearch }. “mod” is ⌘ on Apple devices and Ctrl elsewhere. Single keys never fire while the person types in a field, and with a scope the keys only work while focus is inside one element, so two tools on one page never fight over a letter. comboKeys and useIsApple turn the same combo strings into the key names people see.
Scoped to a panel
import { useId, useRef, useState } from 'react'import { useHotkeys } from '@rchursin/product/lib/hotkeys'import { KeyCombo, TextInput } from '@rchursin/product/ui'
/** * Keys only count while focus is inside this panel (`scope`), so they never fight the rest of the * page. Single keys wait while you type in the field; mod+s has a modifier and `allowInFields`, * so it works there too (and the browser's own Save dialog stays shut). */const shortcuts = [ { combo: 'c', action: 'Create task' }, { combo: 'shift+d', action: 'Duplicate task' }, { combo: '?', action: 'Show shortcuts' }, { combo: 'mod+s', action: 'Save' }, { combo: 'esc', action: 'Clear the log' },]
let next = 0
export default function HotkeysScoped() {Usage
import { useHotkeys, comboKeys, useIsApple, isTypingTarget } from '@rchursin/product/lib/hotkeys'const panel = useRef<HTMLDivElement>(null)useHotkeys({ c: createTask, 'shift+d': duplicate, 'mod+s': save }, { scope: panel, allowInFields: true })Examples
Key names for each keyboard
comboKeys prints a combo for Apple keyboards or for the rest; useIsApple says which one this visitor has. Every string here also works as a useHotkeys binding.
import { comboKeys, useIsApple } from '@rchursin/product/lib/hotkeys'import { Kbd } from '@rchursin/product/ui'
/** * comboKeys turns a combo string into the key names to print, for either keyboard. useIsApple * says which one this visitor has: false in the prerendered page, then the real answer right * after hydration, so the markup never mismatches. */const combos = ['mod+k', 'mod+shift+p', 'alt+enter', 'shift+up', 'esc', 'backspace']
function Keys({ keys }: { keys: string[] }) { return ( <span className="inline-flex flex-wrap items-center gap-1"> {keys.map((k, i) => ( <Kbd key={i}>{k}</Kbd> ))} </span> )}
What counts as typing
isTypingTarget is the check useHotkeys runs before a single-key shortcut. Tab through the controls to see its answer for each.
import { useState, type CSSProperties, type FocusEvent } from 'react'import { isTypingTarget } from '@rchursin/product/lib/hotkeys'import { Button, Checkbox, Select, TextInput } from '@rchursin/product/ui'
/** * isTypingTarget is the check useHotkeys runs before a single-key shortcut: text fields, * textareas, selects (they have type-ahead) and editable text count as typing; checkboxes, * sliders and buttons don't. Move focus through the controls to see each answer. */export default function HotkeysTypingGuard() { const [seen, setSeen] = useState<{ name: string; typing: boolean } | null>(null) const [hours, setHours] = useState(3)
function onFocus(e: FocusEvent<HTMLDivElement>) { const el = e.target as HTMLElement & { labels?: NodeListOf<HTMLLabelElement> } const name = el.getAttribute('aria-label') ?? el.labels?.[0]?.textContent ?? el.textContent ?? '' setSeen({ name: name.trim() || 'This control', typing: isTypingTarget(e.target) }) }
return (API
useHotkeys(map, options?)
| Prop | Type | Default | Description |
|---|---|---|---|
maprequired | Record<string, (e: KeyboardEvent) => void> | – | Combo → handler. A combo is modifiers (mod, shift, alt) and one key joined by “+”. The key is e.key in lower case (a, 1, ?, /, enter, tab, pageup) or one of the short names comboKeys prints: esc, space, up, down, left, right. Read on every key press, so inline handlers are fine. |
options.scope | RefObject<HTMLElement | null> | – | Only fire while focus is inside this element. While the ref is still empty (not mounted), keys fire page-wide. |
options.enabled | boolean | true | Turn the whole map off, e.g. while a dialog is open. |
options.allowInFields | boolean | false | Let combos with mod or alt fire while typing in a field. Single keys and shift+key never fire in fields. |
options.ignoreOverlays | boolean | false | Leave keys pressed inside an open <dialog> or a [popover] to that overlay (unless the overlay holds the scope itself). |
returns | void | – | Nothing. The listener is removed on unmount. |
comboKeys(combo, apple?)
| Prop | Type | Default | Description |
|---|---|---|---|
comborequired | string | – | "mod+shift+k". Knows mod, shift, alt, enter, esc, up, down, left, right, space, backspace, delete, pageup and pagedown (and the e.key names escape and arrowup…). useHotkeys reads the same names, so one string is both the binding and its label. |
apple | boolean | isApple | Which keyboard to print for. Pass useIsApple() in components so prerendered pages match. |
returns | string[] | – | ["⌘", "⇧", "K"] on Apple, ["Ctrl", "⇧", "K"] elsewhere. Unknown names are capitalised. |
useIsApple() · isApple · modKey
| Prop | Type | Default | Description |
|---|---|---|---|
useIsApple() | () => boolean | – | false during the prerender and hydration, then the real answer. Use it for anything rendered. |
isApple | boolean | – | Read from navigator once, when the module loads. In a prerender that is the build machine’s Node, so don’t render it; use useIsApple(). |
modKey | '⌘' | 'Ctrl' | – | The mod key’s name, for text built in event handlers (toasts, hints shown after a press). |
isTypingTarget(el)
| Prop | Type | Default | Description |
|---|---|---|---|
elrequired | EventTarget | null | – | Usually e.target of a key event. |
returns | boolean | – | true for editable text, <textarea>, <select> and text-like <input>s; false for checkboxes, radios, buttons, range, color and file inputs. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| CShiftD? | In the first example: logged while focus is in the panel, but not while typing in its field. |
| CtrlS | Logged anywhere in the panel, the field included (it has a modifier and allowInFields is on). The browser’s Save dialog doesn’t open. |
| Esc | Clears the log, from anywhere in the panel except the field. |
- Single-key shortcuts only fire while focus is inside the scope, which is how they meet WCAG 2.1.4: a speech-input user saying words elsewhere on the page never triggers them.
- The handler runs on keydown and calls preventDefault, so the browser’s own action for that combo (⌘S, /) doesn’t happen as well.
- Keys typed with an input method (e.isComposing) and keys another handler already handled (e.defaultPrevented) are ignored.
- Scoped maps listen on document and unscoped ones on window. A key press reaches document first, so a scoped binding wins over the same key bound page-wide (like the site search’s /), whatever order they mounted in. Among maps of the same kind, the one registered first wins.
- Shortcuts are invisible: list them next to the controls they trigger (KeyCombo, or the shortcut prop on Button and Menu items).
Guidelines
Do
- Give single-key shortcuts a scope, so they only work where they make sense.
- Use the same combo string for the binding and its hint (KeyCombo), so they can’t drift apart.
- Write arrows as left, right, up and down: the binding and the key caps then read the same.
Don’t
- Don’t bind keys the browser or screen readers rely on: Tab, Space on buttons, ⌘L, ⌘W.
- Don’t use alt+letter on Mac keyboards: Option changes the letter typed (Option+S types ß), so it never matches.
- Don’t bind the same combo twice on one page without scopes.
In use
Used on this site
Related
- Key capsKeyboard keys cut from the same metal as the buttons, and shortcuts written as key caps.
- Command paletteOne search field over every page and action, opened with ⌘K, grouped and ranked, run with Enter.
- MenuA menu button with actions, checkbox and radio items, group labels, shortcut hints and type-ahead, in a popover beside its trigger.
- TasksThe whole task tracker: a keyboard-first list and board, a detail view, bulk edits, and undo for every change.
Source
The component’s own files, exactly as they run on this site.
/** * Single-key and chord shortcuts, the way Linear and GitHub do them: * useHotkeys({ c: createTask, 'mod+k': openCommand, '?': showHelp }, { scope: ref }) * `mod` is ⌘ on Apple devices and Ctrl elsewhere. Keys are e.key in lower case, plus the short * names comboKeys prints (esc, space, up, down, left, right), so one combo string serves as both * the binding and its <KeyCombo> label. Shortcuts never fire while the person is typing in a * field (unless the key has a modifier and `allowInFields` is set). * With a `scope`, keys only fire while focus is inside that element (so two demos on one page * don't fight over the same letter), and they win over the same key in a global map: scoped * listeners sit on `document`, global ones on `window`, and a keydown reaches `document` first. * With `ignoreOverlays`, keys pressed inside a dialog or popover opened from the scope are left * to that overlay. */import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react"
export const isApple = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)export const modKey = isApple ? "⌘" : "Ctrl"
export function isTypingTarget(el: EventTarget | null) {