An enhanced, customizable select input component for Ink that supports both vertical and horizontal orientations, hotkeys, and flexible rendering. Ideal for building rich, interactive CLI apps with React.
- Orientation: Choose between vertical or horizontal layouts.
- Custom Indicators & Items: Easily swap out the default indicator and item rendering.
- Hotkey Support: Assign single-character hotkeys for quick selection.
- Disabled Items: Gracefully skip unselectable items during navigation.
- Keyboard Navigation: Arrow keys, Vim-like keys (
h/j/k/l), Home/End supported. - Hooks for Highlight & Selection: Run custom logic on highlight and selection changes.
- Limit Displayed Items: Restrict how many options to show at once, with optional scroll indicators.
- Multi-select Mode: Space to toggle, Enter to confirm a multi-item selection.
- Searchable Mode: Type to filter items inline with case-insensitive matching.
- Item Groups: Organize items under non-navigable section headers.
- Cancel / Escape:
onCancelprop for multi-step CLI "go back" flows. - Headless Hook:
useEnhancedSelectInputfor fully custom renderers with built-in behavior.
| Dependency | Required Version |
|---|---|
| Node.js | >= 20 |
| React | >= 19 |
| Ink | >= 6 |
For Ink 5 / React 18 support, use
ink-enhanced-select-input@0.2.0.
npm install ink-enhanced-select-input ink reactor
yarn add ink-enhanced-select-input ink reactimport React from 'react'
import { render, Text } from 'ink'
import { EnhancedSelectInput } from 'ink-enhanced-select-input'
const items = [
{ label: 'Option 1', value: 'one', hotkey: '1' },
{ label: 'Option 2', value: 'two', hotkey: '2' },
{ label: 'Option 3', value: 'three', disabled: true },
{ label: 'Option 4', value: 'four', hotkey: '4' },
]
function Demo() {
return (
<EnhancedSelectInput
items={items}
onSelect={(item) => console.log(`Selected: ${item.value}`)}
onHighlight={(item) => console.log(`Highlighted: ${item.value}`)}
/>
)
}
render(<Demo />)<EnhancedSelectInput
items={items}
orientation="horizontal"
onSelect={(item) => console.log(item.value)}
/>Enable multi-select mode with the multiple prop. Space toggles an item; Enter confirms the full selection.
import React, { useState } from 'react'
import { render, Text } from 'ink'
import { EnhancedSelectInput } from 'ink-enhanced-select-input'
const options = [
{ label: 'TypeScript', value: 'ts' },
{ label: 'React', value: 'react' },
{ label: 'Ink', value: 'ink' },
{ label: 'Legacy (unsupported)', value: 'legacy', disabled: true },
]
function MultiDemo() {
return (
<EnhancedSelectInput
items={options}
multiple
defaultSelectedKeys={['ts']}
onToggle={(item, checked) =>
console.log(`${item.label} is now ${checked ? 'checked' : 'unchecked'}`)
}
onConfirm={(selected) =>
console.log(
'Confirmed:',
selected.map((i) => i.value)
)
}
/>
)
}
render(<MultiDemo />)<EnhancedSelectInput
items={[
{ label: 'Save', value: 'save', indicator: <Text color="green">✔</Text> },
{ label: 'Delete', value: 'delete', indicator: <Text color="red">✘</Text> },
{ label: 'Cancel', value: 'cancel', hotkey: 'c' },
]}
onSelect={(item) => console.log(item.value)}
/>Group items under section headers by setting the group field. Items sharing the same group value are visually grouped, and a header row is rendered before the first item in each group. Headers are purely visual — they are non-navigable and do not affect selection.
<EnhancedSelectInput
items={[
{ label: 'Option A', value: 'a', group: 'Recent' },
{ label: 'Option B', value: 'b', group: 'Recent' },
{ label: 'Option C', value: 'c', group: 'All' },
{ label: 'Option D', value: 'd', group: 'All' },
]}
onSelect={(item) => console.log(item.value)}
/>Renders:
── Recent ──
> Option A
Option B
── All ──
Option C
Option D
You can provide a custom header renderer via groupHeaderComponent:
<EnhancedSelectInput
items={items}
groupHeaderComponent={({ label }) => (
<Text bold color="cyan">
{label}
</Text>
)}
/>Enable inline filtering with the searchable prop. Printable characters build a search query that filters items by label (case-insensitive substring match). A search input line renders above the item list.
<EnhancedSelectInput
items={items}
searchable
searchPlaceholder="Filter options..."
onSelect={(item) => console.log(item.value)}
/>Renders:
/ Filter options...
> Option A
Option B
Option C
When typing:
/ app
> Apple
Pineapple
Key behavior in searchable mode:
- Printable characters are captured as search input
Backspaceremoves the last character from the queryEscapeclears the query; if already empty, callsonCancel- Arrow keys navigate the filtered results
- Vim keys (
h/j/k/l) are treated as search characters, not navigation - Hotkeys are disabled (characters go to the search query)
- "No matches" is shown when the query matches nothing
Because Ink does not support event propagation stopping, every useInput handler in your app receives every keypress simultaneously. If your application already binds one of the component's default keys globally, you can disable individual key groups with the keyMap prop — the component ignores those keys without interfering with your own handlers.
// j/k are used by a parent vim-style navigator — disable them here
<EnhancedSelectInput
items={items}
keyMap={{ vimKeys: false }}
onSelect={onSelect}
/>
// Parent handles Escape itself — don't fire onCancel
<EnhancedSelectInput
items={items}
keyMap={{ cancel: false }}
onSelect={onSelect}
/>
// Arrows-only navigation — disable vim keys, Home/End, and Space toggle
<EnhancedSelectInput
items={items}
multiple
keyMap={{ vimKeys: false, homeEnd: false, toggle: false }}
onConfirm={onConfirm}
/>keyMap field |
Keys it controls | Default |
|---|---|---|
arrows |
↑ ↓ ← → |
true |
vimKeys |
j k (vertical) · h l (horizontal) |
true |
homeEnd |
Home · End |
true |
cancel |
Escape → onCancel |
true |
select |
Enter → onSelect / onConfirm |
true |
toggle |
Space toggle in multi-select mode |
true |
Any field not supplied stays enabled. isFocused={false} remains the way to disable all input at once.
function MyIndicator({ isSelected }) {
return (
<Text color={isSelected ? 'magenta' : undefined}>
{isSelected ? '👉' : ' '}
</Text>
)
}
function MyItem({ isSelected, isDisabled, label }) {
return (
<Text
color={isDisabled ? 'gray' : isSelected ? 'yellow' : 'white'}
dimColor={isDisabled}
>
{label}
</Text>
)
}
;<EnhancedSelectInput
items={items}
indicatorComponent={MyIndicator}
itemComponent={MyItem}
/>If you need a fully custom renderer while keeping the built-in navigation, hotkeys, pagination, and callbacks, import useEnhancedSelectInput directly:
import { useEnhancedSelectInput } from 'ink-enhanced-select-input'
function MyCustomSelect({ items, onSelect }) {
const { selectedIndex, visibleItems, itemsAbove, itemsBelow } =
useEnhancedSelectInput({ items, onSelect })
return (
<Box flexDirection="column">
{itemsAbove > 0 && <Text dimColor>↑ {itemsAbove} more</Text>}
{visibleItems.map((item, i) => (
<Text
key={item.key ?? String(item.value)}
color={i === selectedIndex ? 'cyan' : undefined}
>
{item.label}
</Text>
))}
{itemsBelow > 0 && <Text dimColor>↓ {itemsBelow} more</Text>}
</Box>
)
}The hook accepts all the same props as EnhancedSelectInput except indicatorComponent, itemComponent, groupHeaderComponent, showScrollIndicators, and searchPlaceholder. It returns { selectedIndex, rotateIndex, visibleItems, hasItems, itemsAbove, itemsBelow, checkedKeys, searchQuery }. checkedKeys is a Set<string> of checked item keys — only populated when multiple is true. searchQuery is the current filter string — empty when searchable is false.
| Prop | Type | Default | Description |
|---|---|---|---|
items |
Array<Item<V>> |
required | List of selectable items |
isFocused |
boolean |
true |
Whether the component responds to input |
initialIndex |
number |
0 |
Index of the initially highlighted item |
limit |
number |
— | Max number of visible items |
indicatorComponent |
FC<IndicatorProps> |
DefaultIndicatorComponent |
Custom selection indicator |
itemComponent |
FC<ItemProps> |
DefaultItemComponent |
Custom item renderer |
onSelect |
(item: Item<V>) => void |
— | Called on selection (Enter or hotkey) — single-select only |
onHighlight |
(item: Item<V>) => void |
— | Called when the highlighted item changes |
onCancel |
() => void |
— | Called when Escape is pressed |
orientation |
'vertical' | 'horizontal' |
'vertical' |
Layout direction |
showScrollIndicators |
boolean |
false |
Show ▲/▼ or ◀/▶ counts when limit clips the list |
multiple |
boolean |
false |
Enable multi-select mode (Space toggles, Enter confirms) |
defaultSelectedKeys |
string[] |
— | Pre-checked item keys for multi-select |
onConfirm |
(items: Array<Item<V>>) => void |
— | Called on Enter in multi-select mode with all checked items |
onToggle |
(item: Item<V>, checked: boolean) => void |
— | Called each time an item is toggled in multi-select mode |
groupHeaderComponent |
FC<GroupHeaderProps> |
DefaultGroupHeaderComponent |
Custom group header renderer |
searchable |
boolean |
false |
Enable inline search/filter mode |
searchPlaceholder |
string |
'Search...' |
Placeholder text shown when search query is empty |
keyMap |
KeyMap |
all enabled | Selectively disable built-in key groups to avoid conflicts |
type Item<V> = {
key?: string // Required when V is an object — see note below
label: string
value: V
hotkey?: string
indicator?: React.ReactNode
disabled?: boolean
group?: string // Items with the same group are rendered under a shared header
}
keyfield: React useskey(orString(value)as a fallback) to track list items. WhenVis a non-primitive type such as an object,String(value)always produces"[object Object]", causing duplicate key warnings and potential rendering bugs. Always setkeyto a stable unique string whenvalueis an object.
| Orientation | Previous | Next | First | Last | Select / Confirm | Toggle (multi) | Cancel |
|---|---|---|---|---|---|---|---|
| Vertical | ↑ / k |
↓ / j |
Home |
End |
Enter |
Space |
Escape |
| Horizontal | ← / h |
→ / l |
Home |
End |
Enter |
Space |
Escape |
In single-select mode, Enter calls onSelect and hotkeys select immediately. In multi-select mode (multiple={true}), Space toggles the highlighted item and Enter calls onConfirm with all checked items. Hotkeys are disabled in multi-select mode to avoid ambiguity with Space.
Disabled items are automatically skipped during navigation, including by Home and End.
Escape calls the onCancel prop when provided — useful for multi-step CLI flows that need a "go back" action.
Hotkey constraints: Navigation keys take priority over hotkeys. In vertical orientation the characters
jandkare reserved for navigation — an item hotkey set to either of these values will never fire. Similarly,handlare reserved in horizontal orientation. Choose hotkeys outside these sets to avoid conflicts.
Searchable mode: When
searchableis enabled, vim keys and hotkeys are disabled — all printable characters go to the search query. Arrow keys still navigate. Backspace edits the query. Escape clears it.
git clone https://github.com/gfargo/ink-enhanced-select-input.git
cd ink-enhanced-select-input
yarn install| Command | Description |
|---|---|
yarn build |
Compile TypeScript to dist/ |
yarn start |
Build and run the interactive storybook demo |
yarn test |
Build and run tests |
yarn lint |
Check formatting and lint |
yarn lint:fix |
Auto-fix formatting and lint issues |
Contributions are welcome. Feel free to open issues, submit pull requests, or provide feedback.