Skip to content

useInput

Observe the low-level (input, key) keyboard tuple. useInput is the Ink-compatible escape hatch for mixed or protocol-aware handlers; new code should choose the semantic hook that matches its intent:

IntentPreferred API
Bind a commanduseHotkey
Bind several commandsuseHotkeyMap
Receive typed or pasted textuseTextInput
Observe every raw key eventuseRawKeyEvent

Use useInput when maintaining Ink-compatible code or when one handler genuinely needs the normalized base key and the full Key object together.

Import

tsx
import { useInput } from "silvery"

Usage

tsx
function App() {
  const [count, setCount] = useState(0)

  useInput((input, key) => {
    if (key.upArrow) setCount((c) => c + 1)
    if (key.downArrow) setCount((c) => c - 1)
    if (input === "q") process.exit()
  })

  return <Text>Count: {count}</Text>
}

Signature

tsx
useInput(
  callback: (input: string, key: Key) => void | "exit",
  options?: {
    isActive?: boolean
    onPaste?: (text: string) => void
    onRelease?: (input: string, key: Key) => void | "exit"
  }
)

Parameters

ParameterTypeDescription
callback(input: string, key: Key) => void | "exit"Called for press and repeat events; return "exit" to exit
options.isActivebooleanWhether to listen for input (default: true)
options.onPaste(text: string) => voidCalled once for each bracketed paste
options.onRelease(input: string, key: Key) => void | "exit"Called for release events when the terminal reports them

Key Object

PropertyTypeDescription
upArrowbooleanUp arrow key
downArrowbooleanDown arrow key
leftArrowbooleanLeft arrow key
rightArrowbooleanRight arrow key
returnbooleanEnter/Return key
escapebooleanEscape key
ctrlbooleanControl modifier
shiftbooleanShift modifier
metabooleanTerminal Alt/Option modifier
superbooleanCommand/Windows/Super modifier
hyperbooleanHyper modifier
tabbooleanTab key
backspacebooleanBackspace key
deletebooleanDelete key
pageUpbooleanPage Up key
pageDownbooleanPage Down key
eventType"press" | "repeat" | "release" | undefinedEvent type when the terminal reports it
isModifierOnlyboolean | undefinedWhether this event is a modifier key pressed alone
textstring | undefinedActual text produced by the key, before key normalization

Normalized key identity vs typed text

The two callback arguments answer different questions:

  • input identifies the normalized base key for physical keybindings. Shifted punctuation is decomposed, so pressing ? produces input === "/" with key.shift === true.
  • key.text contains the character the key produced, so the same press has key.text === "?".

Prefer useHotkey for commands. Its binding makes the distinction explicit:

tsx
// Match the physical Shift+/ chord.
useHotkey("shift+/", showPhysicalShiftSlashHelp)

// Match the produced character, regardless of keyboard layout.
useHotkey("?", showQuestionMarkHelp)

When migrating a raw handler, match a physical chord with input plus modifier fields and match a literal character with key.text. Do not compare input to shifted punctuation such as "?" or "+"; those values arrive as the base key ("/" or "=") plus key.shift. useHotkey and matchHotkey() handle both representations across supported terminal protocols.

Examples

tsx
function Menu({ items }: { items: string[] }) {
  const [selected, setSelected] = useState(0)

  useInput((input, key) => {
    if (key.upArrow) {
      setSelected((s) => Math.max(0, s - 1))
    }
    if (key.downArrow) {
      setSelected((s) => Math.min(items.length - 1, s + 1))
    }
    if (key.return) {
      console.log(`Selected: ${items[selected]}`)
    }
  })

  return (
    <Box flexDirection="column">
      {items.map((item, i) => (
        <Text key={i} inverse={i === selected}>
          {i === selected ? "> " : "  "}
          {item}
        </Text>
      ))}
    </Box>
  )
}

Low-level keyboard shortcuts

For new command bindings, prefer useHotkey or useHotkeyMap. This is the equivalent Ink-compatible form:

tsx
function App() {
  const { exit } = useApp()

  useInput((input, key) => {
    // Ctrl+C to exit
    if (input === "c" && key.ctrl) {
      exit()
    }

    // Ctrl+S to save
    if (input === "s" && key.ctrl) {
      save()
    }

    // 'h' for help
    if (input === "h") {
      showHelp()
    }
  })

  return <Text>Press h for help, Ctrl+C to quit</Text>
}

Conditional Input

tsx
function Modal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  // Only listen when modal is open
  useInput(
    (input, key) => {
      if (key.escape) onClose()
    },
    { isActive: isOpen },
  )

  if (!isOpen) return null

  return (
    <Box borderStyle="double">
      <Text>Press Escape to close</Text>
    </Box>
  )
}

Custom text handling

Prefer the built-in TextInput for ordinary text fields. Custom editors should separate edit commands from textual input; useTextInput preserves shifted punctuation, composed text, graphemes, and bracketed paste:

tsx
function CustomEditor({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  useHotkey("Backspace", () => onChange(value.slice(0, -1)))
  useTextInput({ onText: (text) => onChange(value + text) })

  return (
    <Box>
      <Text>{value}</Text>
      <Text inverse> </Text>
    </Box>
  )
}

Vi-style Movements

tsx
function ViNavigation() {
  const [position, setPosition] = useState({ x: 0, y: 0 })

  useInput((input) => {
    switch (input) {
      case "h": // Left
        setPosition((p) => ({ ...p, x: Math.max(0, p.x - 1) }))
        break
      case "j": // Down
        setPosition((p) => ({ ...p, y: p.y + 1 }))
        break
      case "k": // Up
        setPosition((p) => ({ ...p, y: Math.max(0, p.y - 1) }))
        break
      case "l": // Right
        setPosition((p) => ({ ...p, x: p.x + 1 }))
        break
    }
  })

  return (
    <Text>
      Position: ({position.x}, {position.y})
    </Text>
  )
}

Notes

  • Input is only captured when the app is in raw mode (default for render())
  • Multiple useInput hooks can be active simultaneously
  • Use isActive: false to temporarily disable input handling
  • Release and modifier-only events do not reach the main callback; use onRelease or useRawKeyEvent when you need them