TanStack
Guides

Key State Tracking Guide

Use useHeldKeys, useHeldKeyCodes, and useKeyHold to render the current keyboard state. Use useHotkeyHint to reveal shortcut labels while their modifiers are held.

Read key state

tsx
import { useHeldKeys, useHeldKeyCodes, useKeyHold, useHotkeyHint } from '@tanstack/octane-hotkeys'

export function KeyStatus() @{
	const held = useHeldKeys()
	const codes = useHeldKeyCodes()
	const shift = useKeyHold('Shift')
	const hint = useHotkeyHint('Mod+S')

	<div>
		<p>{held.join(' + ') || 'No keys held'}</p>
		@for (const key of held) {
			<p>{key}: {codes[key]}</p>
		}
		@if (shift) { <button type="button">Delete permanently</button> }
		@if (hint) { <kbd>Save</kbd> }
	</div>
}

useHeldKeys

Returns an array of held logical key names, in press order. Names include Shift, Control, Meta, A, Space, and ArrowUp. An empty array means no keys are held.

useHeldKeyCodes

Returns an object that maps logical names to physical event.code values, such as { Shift: 'ShiftLeft', Control: 'ControlRight' }. This lets a debugging display show the physical position associated with a held logical name.

useKeyHold

Returns a boolean for one key. Create separate readers for Shift, Control, Alt, and Meta to show modifier indicators.

The Store selector subscribes to the selected boolean, so unrelated held-key changes do not change that result.

Common patterns

Reading held keys inside a stable callback

For an event handler that needs current state without a render subscription, initialize the shared tracker before the keys are pressed and read it inside the callback:

ts
import { getKeyStateTracker } from '@tanstack/octane-hotkeys'

const tracker = getKeyStateTracker()
const logHeldKeys = () => {
	console.log(tracker.getHeldKeys())
	console.log('Space held:', tracker.isKeyHeld('Space'))
}

Saving the result outside the callback captures an earlier snapshot. These imperative reads do not subscribe the component. Do not destroy the shared tracker when your component is removed.

For a mouse or wheel handler that needs only the event's modifiers, read them directly:

ts
const onWheel = (event: WheelEvent) => {
	if (event.ctrlKey) console.log('Wheel with Control modifier')
}

A browser may also report ctrlKey for a trackpad pinch. That does not necessarily mean a physical Control key is held.

Hold-to-reveal UI

Use the Shift state to switch between Move to Trash and Delete Permanently actions. The key-state example above shows a button only while Shift is held. The same pattern can reveal alternate menu labels or selection controls.

Keyboard shortcut hints

Use a binding-aware hint reader rather than hardcoding Meta on every platform. Mod+S follows the detected platform. A hint is display state, not a registration or a check that a target is focused.

Debugging key display

Combine held logical names with the physical-code map. Format a logical name through formatForDisplay when it is also a valid RegisterableHotkey, and show the code next to it. Keep physical codes in the diagnostic display even when a layout produces a different logical letter.

Modifier-held shortcut hints

ts
const visible = useHotkeyHint('Alt+Shift+[KeyK]')

Holding Alt, Shift, or both reveals this hint. Extra Control hides it, as does releasing all modifiers or blurring the window. Nonmodifier keys are ignored. AltGraph never reveals hints.

Pass { exact: true } to require every binding modifier, or { platform: 'mac' } to resolve Mod explicitly. Supply the same platform used for registration. Combine the result with the action's enabled state. The core equivalent is matchesHeldModifiers(binding, heldKeys, options).

Platform quirks

macOS modifier key behavior

macOS can swallow the keyup event for a non-modifier while a modifier is held. The tracker handles this to keep held state accurate.

Window blur

The tracker clears held keys when the browser window loses focus. Keys released while another window is active therefore do not remain stuck in the UI.

Under the hood

The hooks subscribe to KeyStateTracker through @tanstack/octane-store. The shared tracker manages keyboard listeners and exposes imperative queries.

ts
tracker.getHeldKeys()
tracker.isKeyHeld('Shift')
tracker.isAnyKeyHeld(['Shift', 'Control'])
tracker.areAllKeysHeld(['Shift', 'Control'])

Try useHeldKeys, useKeyHold, and the kitchen sink.