@piyush333/ink-responsive
v0.2.1
Published
Responsive layout components and resize orchestration for Ink TUI apps
Maintainers
Readme
@piyush333/ink-responsive
Responsive layout components and resize handling for Ink TUI apps.
┌─ My App ────────────────────────────────────────────────────────────┐
│ │
│ ╭─── Nav ────────╮ ╭─── Main ────────────────────────────────╮ │
│ │ › Dashboard │ │ Welcome! This text wraps correctly │ │
│ │ Reports │ │ at any terminal width. │ │
│ │ Settings │ │ │ │
│ ╰────────────────╯ ╰─────────────────────────────────────────╯ │
│ │
│ Press Q to quit Terminal: 120×30 │
└─────────────────────────────────────────────────────────────────────┘What it solves: Ink doesn't handle terminal resize well out of the box — content reflows mid-drag and renders stack on top of each other. This library patches that by rendering into the alternate screen buffer, freezing the layout during a drag so content overflows horizontally (with a native scrollbar), and remounting clean once the drag settles.
Installation
npm install @piyush333/ink-responsive ink reactQuick start
import React from 'react';
import { Box, Text, useInput, useApp } from 'ink';
import {
ResponsiveLayout,
Header,
TwoColumnLayout,
ResponsiveText,
startTUI,
} from '@piyush333/ink-responsive';
function App() {
const { exit } = useApp();
useInput((input, key) => {
if (input === 'q' || (key.ctrl && input === 'c')) exit();
});
return (
<ResponsiveLayout>
<Header title="My App" />
<Box flexGrow={1} marginY={1}>
<TwoColumnLayout
left={
<Box flexDirection="column" borderStyle="round" paddingX={1} flexGrow={1}>
<Text bold>Navigation</Text>
<Text>› Dashboard</Text>
<Text> Reports</Text>
</Box>
}
right={
<Box flexDirection="column" borderStyle="round" paddingX={1} flexGrow={1}>
<Text bold>Main Content</Text>
<ResponsiveText>
This text wraps correctly at any terminal width.
</ResponsiveText>
</Box>
}
/>
</Box>
<Box borderStyle="single" paddingX={1}>
<Text dimColor>Press Q to quit</Text>
</Box>
</ResponsiveLayout>
);
}
startTUI(App);npx tsx app.jsxComponents
startTUI(App, options?)
Entry point. Call this instead of Ink's render(). Handles the alternate screen buffer, resize debouncing, and cleanup on exit.
startTUI(App, { debounceMs: 150 });| Option | Type | Default | Description |
| ------------ | -------- | ------- | ---------------------------------------------------- |
| debounceMs | number | 150 | ms to wait after last resize event before remounting |
<ResponsiveLayout>
Root provider. Wraps the app, reads terminal dimensions at mount time, and shares them via context. Every other component in this library must live inside one.
<ResponsiveLayout>
{/* entire app goes here */}
</ResponsiveLayout><Header>
Full-width title bar. Truncates with … when the terminal is too narrow.
┌─ My App ──────────────────────────────────────────────┐<Header title="My App" /><TwoColumnLayout>
Side-by-side columns on wide terminals (≥ 80 cols), stacked vertically on narrow ones. Left column takes ~35% of the width.
wide (≥ 80 cols) narrow (< 80 cols)
╭──────────╮╭──────────╮ ╭──────────────────╮
│ Left ││ Right │ │ Left │
╰──────────╯╰──────────╯ ╰──────────────────╯
╭──────────────────╮
│ Right │
╰──────────────────╯<TwoColumnLayout
left={<Sidebar />}
right={<MainContent />}
/><Grid> + <GridItem>
Equal-width columns. Items flow left-to-right, wrapping to the next row automatically. Use colSpan to make a cell span multiple columns.
columns={3} gap={1}
╭────────────╮ ╭────────────╮ ╭────────────╮
│ Cell 1 │ │ Cell 2 │ │ Cell 3 │
╰────────────╯ ╰────────────╯ ╰────────────╯
╭──────────────────────────────────────────╮
│ Cell 4 (colSpan=3) │
╰──────────────────────────────────────────╯<Grid columns={3} gap={1}>
<GridItem><Stats /></GridItem>
<GridItem><Chart /></GridItem>
<GridItem><Log /></GridItem>
<GridItem colSpan={3}><Footer /></GridItem>
</Grid>| Prop | Type | Default | Description |
| --------- | -------- | -------- | --------------------------------- |
| columns | number | required | Number of equal-width columns |
| gap | number | 0 | Space in characters between cells |
| GridItem Prop | Type | Default | Description |
| ------------- | -------- | ------- | --------------------------------- |
| colSpan | number | 1 | Number of columns this cell spans |
<Banner>
Multi-line ASCII art, aligned left / center / right inside an optional bordered box.
╭──────────────────────────────────────────────────╮
│ ██╗ ██╗███████╗██╗ ██╗ ██████╗ │
│ ██║ ██║██╔════╝██║ ██║ ██╔═══██╗ │
│ ███████║█████╗ ██║ ██║ ██║ ██║ │
╰──────────────────────────────────────────────────╯const ART = `
██╗ ██╗███████╗██╗ ██╗ ██████╗
██║ ██║██╔════╝██║ ██║ ██╔═══██╗
███████║█████╗ ██║ ██║ ██║ ██║
`;
<Banner
art={ART}
align="center"
color="cyan"
borderStyle="round"
borderColor="cyan"
/>| Prop | Type | Default | Description |
| ------------- | ------------------------------- | -------- | -------------------------------------------------------------------------- |
| art | string | required | Multi-line ASCII art. Leading/trailing blank lines stripped automatically. |
| align | 'left' \| 'center' \| 'right' | 'left' | Horizontal alignment |
| color | string | — | Text color |
| borderStyle | string | — | 'single' 'double' 'round' 'bold' 'classic' |
| borderColor | string | — | Border color |
| paddingX | number | 1 | Horizontal padding |
| paddingY | number | 0 | Vertical padding |
<Select>
Single-choice list.
› Apple ← highlighted
Banana
Cherry
↑↓ navigate · enter selectconst options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
];
<Select
options={options}
onSelect={option => console.log(option.value)}
/>| Prop | Type | Default | Description |
| -------------- | -------------------- | -------- | ---------------------------------------------- |
| options | { label, value }[] | required | Items to display |
| onSelect | (option) => void | — | Called with chosen { label, value } on Enter |
| initialIndex | number | 0 | Initially highlighted index |
| isFocused | boolean | true | false suspends keyboard handling |
<MultiSelect>
Multi-choice list.
› ● Apple ← highlighted + selected
○ Banana
● Cherry ← selected
↑↓ navigate · space toggle · enter confirm<MultiSelect
options={options}
initialSelected={['apple']}
onSubmit={selected => console.log(selected.map(o => o.value))}
/>| Prop | Type | Default | Description |
| ----------------- | --------------------- | -------- | --------------------------------------- |
| options | { label, value }[] | required | Items to display |
| onSubmit | (options[]) => void | — | Called with all selected items on Enter |
| initialSelected | any[] | [] | Pre-selected values |
| isFocused | boolean | true | false suspends keyboard handling |
<ResponsiveText>
Text that word-wraps at the correct container width. Ink's built-in <Text wrap> needs an explicit width — this reads it from context automatically.
<ResponsiveText color="green">
This paragraph wraps at the right edge without overflow or duplication,
no matter how wide or narrow the terminal is.
</ResponsiveText>useSize()
Returns { columns, rows } frozen at mount time. Works anywhere inside <ResponsiveLayout>.
const { columns, rows } = useSize();Putting it all together
import React, { useState } from 'react';
import { Box, Text, useInput, useApp } from 'ink';
import {
ResponsiveLayout, Header, Grid, GridItem,
Banner, Select, MultiSelect, startTUI,
} from '@piyush333/ink-responsive';
const ART = `
██╗ ██╗███████╗██╗ ██╗ ██████╗
██║ ██║██╔════╝██║ ██║ ██╔═══██╗
███████║█████╗ ██║ ██║ ██║ ██║
`;
const OPTIONS = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
];
function App() {
const { exit } = useApp();
const [pick, setPick] = useState(null);
const [picks, setPicks] = useState(null);
useInput((input, key) => {
if (input === 'q' || (key.ctrl && input === 'c')) exit();
});
return (
<ResponsiveLayout>
<Banner art={ART} align="center" color="cyan" borderStyle="round" borderColor="cyan" />
<Grid columns={2} gap={1}>
<GridItem>
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} flexGrow={1}>
<Text bold color="yellow">Pick one</Text>
{pick
? <Text color="green">✔ {pick.label}</Text>
: <Select options={OPTIONS} onSelect={setPick} />}
</Box>
</GridItem>
<GridItem>
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1} flexGrow={1}>
<Text bold color="green">Pick many</Text>
{picks
? picks.map(o => <Text key={o.value} color="green">● {o.label}</Text>)
: <MultiSelect options={OPTIONS} onSubmit={setPicks} />}
</Box>
</GridItem>
</Grid>
<Box borderStyle="single" paddingX={1}>
<Text dimColor>Press Q to quit</Text>
</Box>
</ResponsiveLayout>
);
}
startTUI(App);How resize works
During a drag the layout stays frozen — content overflows horizontally with a native scrollbar instead of reflowing. After debounceMs of inactivity, Ink is unmounted, the screen is cleared, and a fresh instance mounts at the new stable terminal size.
Ink's internal resize handler is silenced via a patch tracked in patches/ink+6.8.0.patch, re-applied automatically by postinstall after every npm install.
Requirements
- Node.js 18+
- Ink 6+
- React 18+
- A terminal with ANSI support (Windows Terminal, iTerm2, any modern Linux terminal)
