
May 02, 20256 min read
What started as a quick fix for horizontal orientation in a terminal game has grown into a full-featured select toolkit for Ink – and with 1.0.0, the API is finally stable enough to call done.
Back in December 2024 I shipped the first version of ink-enhanced-select-input because I needed a horizontal select component for Potion Wars and nothing in the ecosystem quite fit. It worked, but the roadmap I outlined at the end of that post was basically a wishlist: pagination, search/filtering, dynamic item updates, better theming hooks. I kept chipping away at those, and the result is 1.0.0.
The API is stable, there are 133 tests covering every interaction mode and edge case, and the feature set now covers what I’d actually want in a production CLI. Here’s a rundown of everything that landed between 0.2.0 and now.
useEnhancedSelectInputThe biggest architectural change in 0.5.0 was extracting all the behavior into a standalone hook. The component itself is now just a thin rendering wrapper around useEnhancedSelectInput. If you want full control over how your select looks but still want navigation, pagination, hotkeys, and all the callbacks handled for you, you can import the hook 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 returns selectedIndex, visibleItems, itemsAbove, itemsBelow, checkedKeys, and searchQuery. It accepts all the same props as the component except the renderer-specific ones (indicatorComponent, itemComponent, etc.). This pattern turned out to be really useful when building CLIs that have strong opinions about their visual style but want the keyboard behavior off the shelf.
The multiple prop (added in 0.6.0) switches the component into checkbox-style selection. Space toggles an item, Enter confirms the full set. You can pre-populate checked items with defaultSelectedKeys, listen for individual toggles with onToggle, and get the final confirmed list via onConfirm.
<EnhancedSelectInput
items={options}
multiple
defaultSelectedKeys={['ts']}
onToggle={(item, checked) => console.log(`${item.label}: ${checked}`)}
onConfirm={(selected) => console.log(selected.map(i => i.value))}
/>One decision worth flagging: hotkeys are intentionally disabled in multi-select mode. Since Space is already taken for toggling, a single-character hotkey pressing the same key would create real ambiguity. Disabling them avoids that entirely.
The searchable prop (1.0.0) gives you type-to-filter inline. Printable characters build up a query string that filters items by label with a case-insensitive substring match. A / query line renders above the list while you’re typing. It works alongside groups, limit/pagination, and disabled items.
<EnhancedSelectInput
items={items}
searchable
searchPlaceholder="Filter options..."
onSelect={(item) => console.log(item.value)}
/>A few things I spent time getting right here. Vim keys (j/k/h/l) are normally navigation shortcuts, but in searchable mode they become search characters instead – typing “jk” to filter a list should just work. Escape clears the query first before firing onCancel, which mirrors the behavior you’d expect from any search field. And when nothing matches, it shows an empty state rather than silently hiding everything.
There was also a real bug caught during the 1.0.0 audit: the backspace handler was only checking key.backspace, but most terminals actually send x7f (the DEL character) when you press backspace, which Ink maps to key.delete. The fix was a one-liner – key.backspace || key.delete – but it only surfaced because the test suite exercised backspace in scenarios where the filter was actively narrowing results. Good reminder that edge-case tests catch real bugs.
Items can now carry a group field. Items sharing the same group string get a visual section header rendered before the first item in the group. The headers are non-navigable – purely cosmetic – and they play nicely with pagination and limit.
<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 as:
── Recent ──
> Option A
Option B
── All ──
Option C
Option DYou can swap in a custom header renderer via groupHeaderComponent if the default separator style doesn’t fit your CLI’s aesthetic.
The limit prop has been around since 0.2.0 but it had a bug: items clipped by limit weren’t reachable via keyboard navigation. That got fixed in 0.4.0 – the visible window now scrolls properly as you navigate.
In 0.5.0, showScrollIndicators was added to surface how many items are hidden above or below the current window. In vertical mode you get ▲ N more and ▼ N more; in horizontal mode it’s ◀ N more and ▶ N more. Only shows up when items are actually clipped.
Before 0.5.0, if you wanted an Escape handler you had to wire up a separate useInput in the parent. Now there’s an onCancel prop that fires on Escape – and in searchable mode, it clears the query first, then fires on the second press. This makes multi-step CLI flows a lot cleaner without juggling two separate input handlers.
Home and End jump to the first and last enabled item, updating the pagination window as they go. Disabled items at the boundaries are skipped. Small quality-of-life things, but the kind of keyboard shortcuts users just expect to work.
The original release had roughly 10 tests, mostly happy-path rendering. 1.0.0 ships with 133. Coverage touches every interaction mode: single-select navigation, multi-select toggling, searchable filtering (including empty states, backspace edge cases, and Escape behavior), grouped items with pagination, scroll indicator visibility, Home/End edge cases, disabled item skipping, hotkey firing, and the headless hook’s state management.
There’s also a dev-only warning that fires when duplicate item keys are detected. When V is an object type, String(value) always produces "[object Object]", which causes subtle rendering bugs. The warning surfaces the problem immediately rather than leaving you to debug mysterious re-render issues.
npm install ink-enhanced-select-input ink reactRequires Node.js 20+, React 19, and Ink 6. If you’re still on Ink 5 / React 18, stick with ink-enhanced-select-input@0.2.0. The package is ESM-only with a proper exports field in package.json, so module resolution should just work.
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 },
]
function Demo() {
return (
<EnhancedSelectInput
items={items}
onSelect={(item) => console.log(item.value)}
/>
)
}For a more involved example combining searchable mode, groups, and a visible limit:
<EnhancedSelectInput
items={[
{ label: 'TypeScript', value: 'ts', group: 'Languages' },
{ label: 'JavaScript', value: 'js', group: 'Languages' },
{ label: 'React', value: 'react', group: 'Frameworks' },
{ label: 'Ink', value: 'ink', group: 'Frameworks' },
{ label: 'Jest', value: 'jest', group: 'Testing' },
{ label: 'Vitest', value: 'vitest', group: 'Testing' },
]}
searchable
searchPlaceholder="Filter..."
limit={4}
showScrollIndicators
onSelect={(item) => console.log(item.value)}
/>It’s MIT licensed with zero runtime dependencies beyond ink and react. If you’re building a CLI with Ink and need a select component that handles more than the basics, give it a try. Issues and pull requests are open – particularly interested in feedback from anyone using the headless hook for custom renderers.
Discussion
Comments are powered by Disqus. Sign in once, comment anywhere.
