# Hotkey

> Parse, label, announce and match keyboard shortcuts, with ⌘ on Apple platforms and Ctrl everywhere else.

Docs: https://hextaui.com/docs/hotkey
Markdown: https://hextaui.com/docs/hotkey.md

```tsx title="components/examples/hotkey/demo.tsx"
"use client"

import * as React from "react"

import { Input } from "@/components/ui/input"
import { Kbd } from "@/components/ui/kbd"
import {
  formatHotkey,
  matchesHotkey,
  parseHotkey,
  spokenKey,
  useIsApple,
} from "@/lib/hotkey"

export function HotkeyDemo() {
  const [hotkey, setHotkey] = React.useState("mod+shift+k")
  const [pressed, setPressed] = React.useState(0)
  const apple = useIsApple()
  const chord = parseHotkey(hotkey)[0] ?? []

  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (
        event.target instanceof HTMLInputElement ||
        !matchesHotkey(event, hotkey)
      ) {
        return
      }
      event.preventDefault()
      setPressed((count) => count + 1)
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [hotkey])

  const rows = [
    ["Apple", formatHotkey(hotkey, true)],
    ["Windows and Linux", formatHotkey(hotkey, false)],
    ["Screen readers", chord.map((key) => spokenKey(key, apple)).join(" ")],
    ["parseHotkey", JSON.stringify(parseHotkey(hotkey))],
  ]

  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <Input
        aria-label="Hotkey"
        value={hotkey}
        onChange={(event) => setHotkey(event.target.value)}
        spellCheck={false}
        autoCapitalize="off"
      />
      <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-2 text-sm">
        {rows.map(([label, value]) => (
          <React.Fragment key={label}>
            <dt className="text-muted-foreground">{label}</dt>
            <dd className="truncate font-mono">{value}</dd>
          </React.Fragment>
        ))}
      </dl>
      <p className="flex items-center gap-2 text-sm text-muted-foreground">
        Click outside the field and press <Kbd keys={hotkey} />
        <span className="ms-auto font-mono tabular-nums">
          matched {pressed}×
        </span>
      </p>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/hotkey.json
```

This adds the utility and anything it depends on.

### Manual

Copy and paste the following code into your project.

```ts title="lib/hotkey.ts"
import * as React from "react"

function subscribePlatform() {
  return () => {}
}

function isApplePlatform() {
  if (typeof navigator === "undefined") {
    return true
  }
  return /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
}

function useIsApple() {
  return React.useSyncExternalStore(
    subscribePlatform,
    isApplePlatform,
    () => true
  )
}

const modifierOrder = ["ctrl", "alt", "shift", "mod", "meta"]

const appleKeys: Record<string, string> = {
  mod: "⌘",
  meta: "⌘",
  ctrl: "⌃",
  alt: "⌥",
  shift: "⇧",
  enter: "↵",
  backspace: "⌫",
  escape: "esc",
  arrowup: "↑",
  arrowdown: "↓",
  arrowleft: "←",
  arrowright: "→",
}

const otherKeys: Record<string, string> = {
  mod: "Ctrl",
  meta: "Win",
  ctrl: "Ctrl",
  alt: "Alt",
  shift: "Shift",
  enter: "Enter",
  backspace: "Backspace",
  escape: "Esc",
  arrowup: "↑",
  arrowdown: "↓",
  arrowleft: "←",
  arrowright: "→",
}

const sharedKeys: Record<string, string> = {
  space: "Space",
  tab: "Tab",
  delete: "Del",
  home: "Home",
  end: "End",
  pageup: "PgUp",
  pagedown: "PgDn",
}

const spokenKeys: Record<string, string> = {
  mod: "Command",
  meta: "Command",
  ctrl: "Control",
  alt: "Option",
  shift: "Shift",
  enter: "Enter",
  backspace: "Delete",
  escape: "Escape",
  arrowup: "Up arrow",
  arrowdown: "Down arrow",
  arrowleft: "Left arrow",
  arrowright: "Right arrow",
}

const otherSpokenKeys: Record<string, string> = {
  mod: "Control",
  meta: "Windows",
  alt: "Alt",
  backspace: "Backspace",
}

function normalizeKey(token: string) {
  const key = token.trim().toLowerCase()
  const aliases: Record<string, string> = {
    cmd: "meta",
    command: "meta",
    control: "ctrl",
    option: "alt",
    opt: "alt",
    return: "enter",
    esc: "escape",
    up: "arrowup",
    down: "arrowdown",
    left: "arrowleft",
    right: "arrowright",
    " ": "space",
  }
  return aliases[key] ?? key
}

function sortModifiers(parts: string[]) {
  const key = parts.at(-1) ?? ""
  const modifiers = parts
    .slice(0, -1)
    .sort((a, b) => modifierOrder.indexOf(a) - modifierOrder.indexOf(b))
  return [...modifiers, key]
}

function parseHotkey(hotkey: string) {
  return hotkey
    .trim()
    .split(/\s+/)
    .filter(Boolean)
    .map((chord) =>
      sortModifiers(
        chord
          .split(/\+(?!$)/)
          .map(normalizeKey)
          .filter(Boolean)
      )
    )
}

function keyLabel(key: string, apple: boolean) {
  const names = apple ? appleKeys : otherKeys
  return (
    names[key] ??
    sharedKeys[key] ??
    (key.length === 1 ? key.toUpperCase() : key[0].toUpperCase() + key.slice(1))
  )
}

function spokenKey(key: string, apple: boolean) {
  return (
    (!apple ? otherSpokenKeys[key] : undefined) ??
    spokenKeys[key] ??
    sharedKeys[key] ??
    keyLabel(key, apple)
  )
}

function parseChord(hotkey: string) {
  return parseHotkey(hotkey.replace(/\s+/g, ""))[0] ?? []
}

function formatHotkey(hotkey: string, apple: boolean) {
  const labels = parseChord(hotkey).map((part) => keyLabel(part, apple))
  return apple ? labels.join("") : labels.join("+")
}

function eventKeys(event: KeyboardEvent) {
  const keys = new Set([normalizeKey(event.key === " " ? "space" : event.key)])
  const physical = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(event.code ?? "")
  if (physical) {
    keys.add((physical[1] ?? physical[2]).toLowerCase())
  }
  return keys
}

function matchesHotkey(event: KeyboardEvent, hotkey: string) {
  const parts = parseChord(hotkey)
  const key = parts.pop()
  const wants = new Set(parts)
  const apple = isApplePlatform()
  const meta = wants.has("meta") || (wants.has("mod") && apple)
  const ctrl = wants.has("ctrl") || (wants.has("mod") && !apple)

  return (
    key !== undefined &&
    eventKeys(event).has(key) &&
    event.metaKey === meta &&
    event.ctrlKey === ctrl &&
    event.altKey === wants.has("alt") &&
    event.shiftKey === wants.has("shift")
  )
}

export {
  formatHotkey,
  isApplePlatform,
  keyLabel,
  matchesHotkey,
  parseHotkey,
  spokenKey,
  useIsApple,
}
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  formatHotkey,
  matchesHotkey,
  parseHotkey,
  useIsApple,
} from "@/lib/hotkey"
```

```tsx
function SaveShortcut({ onSave }: { onSave: () => void }) {
  const apple = useIsApple()

  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if (matchesHotkey(event, "mod+s")) {
        event.preventDefault()
        onSave()
      }
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [onSave])

  return <span>Save {formatHotkey("mod+s", apple)}</span>
}
```

Write each shortcut once, as a string, and use the same string to display it, announce it and match it. `mod` means ⌘ on Apple platforms and Ctrl everywhere else. That's almost always what you want, since Ctrl+K on a Mac and ⌘K on Windows both feel wrong.

## Syntax

```bash
"mod+k"            ⌘K on Apple platforms, Ctrl+K elsewhere
"shift+mod+p"      modifiers in any order
"cmd+option+esc"   aliases: cmd, command, option, opt, control, esc, return
"alt+up"           up, down, left and right for the arrow keys
"mod++"            a literal plus key
"g g"              a sequence of two chords, separated by a space
```

Keys are separated by `+` and are case-insensitive. Named keys use the lowercase `KeyboardEvent.key` value, like `enter`, `tab`, `pageup` or `f5`. Use `space` for the space bar.

## Parsing

```tsx
parseHotkey("mod+shift+k")  // [["shift", "mod", "k"]]
parseHotkey("cmd+opt+esc")  // [["alt", "meta", "escape"]]
parseHotkey("g i")          // [["g"], ["i"]]
```

`parseHotkey` returns one array per chord, with aliases resolved and modifiers sorted into Apple's order: Control, Option, Shift, Command. Every label built from it reads in the order people expect to see it.

## Labels

```tsx
formatHotkey("mod+shift+k", true)   // "⇧⌘K"
formatHotkey("mod+shift+k", false)  // "Shift+Ctrl+K"
keyLabel("enter", true)             // "↵"
spokenKey("mod", true)              // "Command"
spokenKey("mod", false)             // "Control"
```

Apple platforms use symbols with no separator, the way menus show them. Windows and Linux use words joined by `+`. Symbols are hard to read aloud, so `spokenKey` gives the name a screen reader should announce instead. `<Kbd keys>` shows the symbol and puts the spoken name in visually hidden text.

`useIsApple()` picks the platform. It returns `true` on the server and during hydration, then the real answer, so a Windows visitor briefly sees ⌘ before Ctrl instead of getting a hydration error.

## Matching

### Shortcut listener

`matchesHotkey` checks a keydown event against a hotkey. Modifiers must match exactly, so `mod+b` doesn't fire for `mod+shift+b`.

```tsx title="components/examples/hotkey/listener.tsx"
"use client"

import * as React from "react"

import { Kbd } from "@/components/ui/kbd"
import { matchesHotkey } from "@/lib/hotkey"

const shortcuts = [
  { hotkey: "mod+b", label: "Bold" },
  { hotkey: "mod+i", label: "Italic" },
  { hotkey: "mod+shift+x", label: "Strikethrough" },
]

export function HotkeyListener() {
  const [last, setLast] = React.useState<string>()

  React.useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      const match = shortcuts.find((shortcut) =>
        matchesHotkey(event, shortcut.hotkey)
      )
      if (match) {
        event.preventDefault()
        setLast(match.label)
      }
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [])

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <ul className="flex flex-col gap-2 text-sm">
        {shortcuts.map((shortcut) => (
          <li
            key={shortcut.hotkey}
            data-active={last === shortcut.label ? "" : undefined}
            className="flex items-center justify-between rounded-md px-2 py-1 transition-colors duration-150 data-active:bg-muted"
          >
            {shortcut.label}
            <Kbd keys={shortcut.hotkey} />
          </li>
        ))}
      </ul>
      <p role="status" className="text-sm text-muted-foreground">
        {last ? `Matched ${last}` : "Press a shortcut"}
      </p>
    </div>
  )
}
```

- Letters and digits also match by physical key, so `alt+k` works on a Mac, where Option+K types `˚`.
- Shifted letters match: `shift+k` matches the `K` that Shift produces.
- It matches one chord. For sequences like `g i`, track the previous chord yourself.
- Skip shortcuts without modifiers while the focus is in a text field, so typing a letter never triggers a command.

## API reference

| Export | Description |
| --- | --- |
| `parseHotkey(hotkey)` | string[][]: one array per chord, with aliases resolved and modifiers sorted. |
| `formatHotkey(hotkey, apple)` | The label for one chord, such as ⇧⌘K or Shift+Ctrl+K. |
| `keyLabel(key, apple)` | The visible label for one key name. |
| `spokenKey(key, apple)` | The name a screen reader should announce for one key. |
| `matchesHotkey(event, hotkey)` | Whether a KeyboardEvent matches one chord, with exact modifiers. |
| `isApplePlatform()` | Reads navigator.platform. true on the server. |
| `useIsApple()` | isApplePlatform as a hydration-safe hook. |

### Used by

`Kbd` and `Command`.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
