gridland
Hooks

useKeyboard

Subscribe to keyboard events

The useKeyboard hook subscribes to keyboard events inside a <TUI> component. By default it receives press events (including key repeats). Use options.release to also receive release events.

For focus-aware components, prefer useInteractive({ id }).onKey(handler) over useKeyboard({ focusId }). useInteractive composes focus registration, selection-scoped keyboard routing, and shortcut hints in a single call see Focus for the full model. Reach for useKeyboard directly when you need a global shortcut, a raw listener outside the focus system, or a custom reducer on top of KeyEvent.

Import
import { useKeyboard } from "@gridland/utils"

Usage

Pass { global: true } for app-level handlers and { focusId } to scope a handler to a focus id. Calling useKeyboard(handler) without an options bag is deprecated see below.

Basic (global)
useKeyboard((event) => {
  if (event.name === "q") quit()
  if (event.name === "return") submit()
}, { global: true })
With modifiers
useKeyboard((event) => {
  if (event.ctrl && event.name === "s") save()
  if (event.shift && event.name === "Tab") focusPrevious()
}, { global: true })
With release events
useKeyboard((event) => {
  if (event.eventType === "release") {
    heldKeys.delete(event.name)
  } else {
    heldKeys.add(event.name)
  }
}, { global: true, release: true })

Focus-aware routing (escape hatch)

Most focus-aware components should use useInteractive({ id }).onKey(handler) instead of the options below. The raw useKeyboard options are useful when you need a keyboard listener outside the useInteractive lifecycle: global shortcuts, status-bar reducers, or components that manage their own focus id for a reason that useInteractive doesn't cover.

Focus-scoped: only fires when this id is focused
// Low-level form prefer useInteractive({ id }).onKey(handler) for component code
const { focusId } = useInteractive({ id: "my-panel" })
useKeyboard((e) => {
  if (e.name === "return") submit()
}, { focusId })
Global: always fires regardless of focus
useKeyboard((e) => {
  if (e.ctrl && e.name === "q") quit()
}, { global: true })

Parameters

ParamTypeDefaultDescription
handler(key: KeyEvent) => void--Called on each keypress
options.releasebooleanfalseAlso receive key release events
options.focusIdstring--Only fire when this ID is focused (from useInteractive())
options.globalboolean--Always fire regardless of focus state
options.selectedOnlybooleanfalseOnly fire when the component is selected (entered), not just focused. Requires focusId

KeyEvent

The callback receives a KeyEvent with every field populated for most keys. Fields marked Kitty-only are undefined unless the terminal supports the Kitty keyboard protocol.

PropertyTypeDescription
namestringLogical key name ("a", "left", "f1", "escape", "return"). Also set to the literal character for printable punctuation like "[", "]", ";", "/".
ctrlbooleanWhether Ctrl was held.
metabooleanWhether Meta was held. Raw parser also sets this for Alt/Option escape sequences meta and option often co-occur.
shiftbooleanWhether Shift was held. For letters, name is lowercased and shift carries the case.
optionbooleanWhether Option (macOS) / Alt was held.
sequencestringRaw bytes from the terminal. Equals name for single-char input; for named keys this is the ANSI escape sequence ("\x1b[A"), not the name. Prefer name for logic.
numberbooleanTrue if the key is a digit 09.
rawstringLiteral pre-parse bytes. Rarely needed prefer sequence or name.
eventType"press" | "release" | "repeat"Usually "press". Releases and repeats require Kitty keyboard protocol plus { release: true }.
source"raw" | "kitty"Which parser produced the event.
codestring | undefinedANSI code ("[A") or Kitty CSI-u code ("[57352u") for escape sequences. Undefined for single-char input.
superboolean | undefinedSuper / Windows / Command key. Populated by Kitty and by modifyOtherKeys in raw mode.
hyperboolean | undefinedHyper key. Populated by Kitty and by modifyOtherKeys in raw mode.
capsLockboolean | undefinedKitty-only. Whether Caps Lock was on.
numLockboolean | undefinedKitty-only. Whether Num Lock was on.
baseCodenumber | undefinedKitty-only. Base-layout codepoint (for QWERTY vs Dvorak disambiguation).
repeatedboolean | undefinedKitty-only. True for key-repeat events.

KeyEvent also exposes defaultPrevented / propagationStopped (read-only getters) and preventDefault() / stopPropagation() methods. preventDefault() suppresses the framework's focus-navigation logic and stops renderable-scoped listeners from firing. stopPropagation() additionally halts any remaining global listeners. Both are honored by the key dispatcher (KeyHandler) at runtime.

Which field should I read?

Prefer name for almost everything. Reach for sequence or raw only when detecting a specific byte pattern that name does not normalize.

import { useKeyboard, type KeyEvent } from "@gridland/utils"

useKeyboard((event: KeyEvent) => {
  // Letters with modifiers → name + modifier flag
  if (event.ctrl && event.name === "s") save()

  // Arrow keys and named keys → name
  if (event.name === "left") prevItem()
  if (event.name === "escape") closeModal()

  // Function keys → name
  if (event.name === "f1") showHelp()

  // Printable punctuation → name (it is set to the character)
  if (event.name === "[") prevPage()
  if (event.name === "]") nextPage()
}, { global: true })

Deprecation: bare form

Calling useKeyboard(handler) without an options bag is deprecated. Pass { global: true } explicitly for app-level shortcuts, or { focusId } for focus-scoped handlers. The bare form still works at runtime this deprecation exists so every call site states its intent at the source. @gridland/utils ships an overloaded @deprecated type signature so IDE hover surfaces the warning on bare calls.

Before (deprecated)
useKeyboard((event) => { /* ... */ })
After
useKeyboard((event) => { /* ... */ }, { global: true })