Hotkeys
One matcher for a keyboard shortcut, written the way KbdShortcut writes it.
No imports at all, so anything can name it: the command palette and a chart toolbar both do. A hotkey is written the way KbdShortcut writes it — "mod+k" is the platform modifier, so ⌘ on a mac and Ctrl elsewhere; "shift+?" and a bare "c" both work. Two rules keep it out of the reader's way: a held chord fires keydown at the OS repeat rate and is never a match, because toggling on every one of those strobes whatever it opens; and an unmodified key belongs to whatever is being typed into, so it does not fire inside an input, a textarea, a select or a contenteditable. A chord still fires there, because it cannot be typed.
Install
npx shadcn@latest add @vibra/hotkeysNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { matchesHotkey } from "@/lib/hotkeys"
import { Input } from "@/components/ui/input"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
const BINDINGS = [
{ hotkey: "mod+k", label: "Open the palette" },
{ hotkey: "c", label: "Compare periods" },
{ hotkey: "shift+?", label: "Show shortcuts" },
]
export default function HotkeysDemo() {
const [fired, setFired] = React.useState<string | null>(null)
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const hit = BINDINGS.find((binding) => matchesHotkey(event, binding.hotkey))
if (!hit) return
event.preventDefault()
setFired(hit.hotkey)
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [])
return (
<div className="flex w-full max-w-md flex-col gap-4">
<ul className="flex flex-col gap-2">
{BINDINGS.map((binding) => (
<li
key={binding.hotkey}
data-fired={binding.hotkey === fired || undefined}
className="flex items-center justify-between gap-4 rounded-md px-2 py-1.5 text-sm data-[fired]:bg-brand-muted data-[fired]:font-medium"
>
{binding.label}
<KbdShortcut keys={binding.hotkey} />
</li>
))}
</ul>
<Input placeholder="Type here — a bare c belongs to this field, not the page" />
<p className="text-sm text-muted-foreground">
{fired ? `Last match: ${fired}.` : "Press one of the shortcuts above."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| matchesHotkey | (event: KeyboardEvent, hotkey: string) => boolean | — | Whether this keydown is that shortcut. |
| isTypingTarget | (target: EventTarget | null) => boolean | — | Whether the event landed in something the reader is typing into. |
| firstDelivery | () => (event: Event) => boolean | — | A check that passes each native event once. React hands a key pressed inside a nested portal — a Base UI dialog is two portal containers deep — to an ancestor's handler once per container, so a handler that toggles on a chord makes one of these for its life and counts each press once. |
Dependencies
Registry
Source
/**
* One hotkey matcher, written the way KbdShortcut writes a shortcut.
*
* It was a copy in two places — command-palette and search-input — kept there
* deliberately so a palette would not drag a search field in behind it. The two
* had already drifted: only one of them refused a modifier it did not know, and
* only one told cmd from ctrl. This is the stricter of the two. Now that a chart toolbar wants the
* same matcher, the copy is the wrong trade: two of them drift, and the second
* one always forgets `event.repeat` or the typing-target rule. It ships as a
* dependency-free lib item instead — no imports at all, so anything can name it.
*/
const META_TOKENS = new Set(["cmd", "command", "meta"])
const CTRL_TOKENS = new Set(["ctrl", "control"])
const ALT_TOKENS = new Set(["alt", "opt", "option"])
const KNOWN_MODIFIERS = new Set(["mod", "shift", ...META_TOKENS, ...CTRL_TOKENS, ...ALT_TOKENS])
/** Whether the event landed in something the reader is typing into. */
export function isTypingTarget(target: EventTarget | null): boolean {
const element = target as HTMLElement | null
if (!element?.tagName) return false
const tag = element.tagName.toLowerCase()
return tag === "input" || tag === "textarea" || tag === "select" || element.isContentEditable
}
/**
* Whether a keydown is the hotkey, written as KbdShortcut writes it: "mod+k",
* "shift+?", or a bare "c".
*
* A bare key belongs to whatever the reader is typing into, so it only fires
* outside a field; a chord fires anywhere. A held chord repeats at the OS rate,
* and toggling on every one of those would strobe whatever it opens, so a
* repeat is never a match.
*/
export function matchesHotkey(event: KeyboardEvent, hotkey: string): boolean {
const parts = hotkey
.toLowerCase()
.split("+")
.map((part) => part.trim())
.filter(Boolean)
if (parts.length === 0) return false
const key = parts[parts.length - 1]
const modifiers = parts.slice(0, -1)
// A token neither side understands must not quietly become no modifier at
// all: "win+k" is better off never firing than firing on every bare K.
if (modifiers.some((modifier) => !KNOWN_MODIFIERS.has(modifier))) return false
// "mod" is the one drawn as whichever key the reader's platform uses, so
// either satisfies it. Naming a specific key means that key and not the other.
const wantsMod = modifiers.includes("mod")
const wantsMeta = modifiers.some((modifier) => META_TOKENS.has(modifier))
const wantsCtrl = modifiers.some((modifier) => CTRL_TOKENS.has(modifier))
const wantsAlt = modifiers.some((modifier) => ALT_TOKENS.has(modifier))
if (event.repeat) return false
if (wantsMod) {
if (!event.metaKey && !event.ctrlKey) return false
} else {
if (wantsMeta !== event.metaKey) return false
if (wantsCtrl !== event.ctrlKey) return false
}
if (modifiers.includes("shift") !== event.shiftKey) return false
if (wantsAlt !== event.altKey) return false
if (event.key.toLowerCase() !== key) return false
return wantsMod || wantsMeta || wantsCtrl || wantsAlt || !isTypingTarget(event.target)
}
/**
* A check that passes each native event once. React hands a key pressed
* inside a nested portal — a Base UI dialog is two portal containers deep — to
* an ancestor's handler once for every container the event passes, capture
* and bubble alike, so a handler that toggles something on a chord (a band's
* ⌘K around its palette) saw each press twice. Make one per handler and keep
* it for the component's life — `const [once] = React.useState(firstDelivery)`
* — and return early unless `once(event.nativeEvent)`. One per handler, never
* shared: a second handler asking about the same press must still hear it.
*/
export function firstDelivery(): (event: Event) => boolean {
const seen = new WeakSet<Event>()
return (event) => {
if (seen.has(event)) return false
seen.add(event)
return true
}
}