HextaUI

useHeldKeys

The keys someone is holding down right now, shared by every subscriber through a single set of window listeners.

Hold down any keys
[]
"use client"

import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { useHeldKeys } from "@/hooks/use-held-keys"

export function UseHeldKeysDemo() {
  const held = useHeldKeys(true)

  return (
    <div className="flex flex-col items-center gap-3">
      <div className="flex h-8 items-center">
        {held.size > 0 ? (
          <KbdGroup>
            {[...held].map((key) => (
              <Kbd key={key} keys={key} size="lg" />
            ))}
          </KbdGroup>
        ) : (
          <span className="text-sm text-muted-foreground">
            Hold down any keys
          </span>
        )}
      </div>
      <code className="font-mono text-xs text-muted-foreground">
        {JSON.stringify([...held])}
      </code>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/use-held-keys.json

Adds the hook and anything it depends on to your project.

import { useHeldKeys } from "@/hooks/use-held-keys"
const held = useHeldKeys(true)
const showShortcutHints = held.has("meta") || held.has("ctrl")

Use it for anything that reacts to keys being held rather than pressed: keycaps that press down, shortcut hints that appear while you hold ⌘, or a modifier that switches a tool, like Alt to duplicate while dragging.

Every component that calls the hook shares one store. The first subscriber adds passive keydown and keyup listeners to window, and the last one to unsubscribe removes them. A page with fifty listening keycaps still has one pair of listeners.

The listeners only read events. They never call preventDefault, and they sit on window, after React's own handlers, so typing in a field is never delayed or changed.

"meta" "ctrl" "alt" "shift"     modifiers, left and right alike
"a" … "z"  "0" … "9"            letters and digits, by physical key
"space" "enter" "escape" "tab"   named keys, lowercased
"arrowup" "arrowdown" "f1" …
  • Letters and digits come from event.code, the physical key, so holding Option+K on a Mac still reports k rather than ˚.
  • Key repeat is ignored, and nothing re-renders while a key is held.
  • macOS doesn't send keyup for other keys while ⌘ is down. When ⌘ is released, the store keeps only the modifiers still held, so letters can't get stuck.
  • Everything is released when the window loses focus or the tab is hidden. A shortcut that switches apps leaves nothing held.

Keycaps that press

<Kbd listen> is built on this hook. Each keycap presses down while its key is held.

CommandShiftP

Hold the modifiers to see the hint

"use client"

import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { useHeldKeys } from "@/hooks/use-held-keys"
import { useIsApple } from "@/lib/hotkey"

export function UseHeldKeysShortcut() {
  const held = useHeldKeys(true)
  const apple = useIsApple()
  const modifier = apple ? "meta" : "ctrl"
  const ready = held.has(modifier) && held.has("shift")

  return (
    <div className="flex flex-col items-center gap-3 text-sm">
      <KbdGroup>
        <Kbd keys="mod" listen />
        <Kbd keys="shift" listen />
        <Kbd keys="p" listen />
      </KbdGroup>
      <p className="text-muted-foreground">
        {ready ? "Now press P" : "Hold the modifiers to see the hint"}
      </p>
    </div>
  )
}
  • Pass false to stop listening. The hook then returns an empty set and adds no listeners, so it's cheap to call it unconditionally.
  • The set is replaced only when a key goes down or up, so its identity works as a memo or effect dependency.
  • For shortcuts that fire an action, use matchesHotkey from Hotkey in a keydown handler instead. Holding keys is for showing state, not for running commands.
  • On the server, and before hydration, the set is empty.
PropTypeDefault
enabledWhether to listen. When false, nothing is attached.
boolean–
ReturnsDescription
ReadonlySet<string>The names of the keys held down right now.

Kbd and KbdGroup through their listen prop.