Skip to content

2.4 Handling Terminal Resize

Every TUI that runs for more than a few seconds will be resized. A program that ignores resize either looks broken (content outside the new bounds) or crashes (writing beyond the terminal edge). Handling it correctly takes about 10 lines of code.

When the user resizes the terminal window, the OS sends SIGWINCH (SIGnal WINdow CHange) to the foreground process. Node.js exposes this as a 'resize' event on process.stdout:

process.stdout.on('resize', () => {
const cols = process.stdout.columns;
const rows = process.stdout.rows;
console.error(`Resized to ${cols}x${rows}`);
render(); // full re-render with new dimensions
});

process.stdout.columns and process.stdout.rows always reflect the current terminal size — they update before the 'resize' event fires.

ProblemCause
Content cut offLayout computed once at startup with old dimensions
Borders misalignedColumn count cached, used for drawing box edges
Crash on width/indexAccessing columns[col] where col >= newCols
Scrollback garbageContent written past the new terminal height

The key rule: always read process.stdout.columns and process.stdout.rows at the start of every render() call. Never store them in a variable that outlives a single frame.

// ❌ Bad: cached at startup, stale after resize
const COLS = process.stdout.columns;
const ROWS = process.stdout.rows;
function render() {
const box = ''.repeat(COLS - 2); // wrong after resize
}
// ✅ Good: read fresh every frame
function render() {
const cols = process.stdout.columns;
const rows = process.stdout.rows;
const box = ''.repeat(cols - 2); // always correct
}

Here is a complete program that handles resize gracefully:

examples/module-2/04-resize.ts
import chalk from 'chalk';
import ansiEscapes from 'ansi-escapes';
import { parseKey } from './01-keypress-parser';
type State = {
selectedIndex: number;
items: string[];
};
const state: State = {
selectedIndex: 0,
items: ['Option A', 'Option B', 'Option C', 'Option D', 'Option E'],
};
function render() {
const cols = process.stdout.columns; // fresh every render
const rows = process.stdout.rows;
process.stdout.write(ansiEscapes.cursorTo(0, 0));
// ── title bar
const title = ' Resize Demo ';
const titlePad = Math.floor((cols - title.length) / 2);
process.stdout.write(
chalk.bgCyan.black(' '.repeat(titlePad) + title + ' '.repeat(cols - titlePad - title.length)) + '\n'
);
// ── size indicator
process.stdout.write(chalk.dim(` Terminal: ${cols}×${rows}\n`));
process.stdout.write(chalk.dim(''.repeat(cols) + '\n'));
// ── menu items (fit within available rows)
const availableRows = rows - 5; // title(1) + size(1) + divider(1) + footer(2)
const visibleItems = state.items.slice(0, availableRows);
for (let i = 0; i < visibleItems.length; i++) {
const isSelected = i === state.selectedIndex;
const prefix = isSelected ? '' : ' ';
const line = `${prefix}${visibleItems[i]}`.padEnd(cols);
process.stdout.write(
isSelected ? chalk.bgBlue.white(line) : chalk.white(line)
);
process.stdout.write('\n');
}
// ── footer
process.stdout.write(ansiEscapes.cursorTo(0, rows - 2));
process.stdout.write(chalk.dim(''.repeat(cols) + '\n'));
process.stdout.write(chalk.dim(' ↑/↓ navigate q quit'));
process.stdout.write(ansiEscapes.eraseDown);
}
function handleKey(raw: string) {
const key = parseKey(raw);
if (key.name === 'up') state.selectedIndex = Math.max(0, state.selectedIndex - 1);
if (key.name === 'down') state.selectedIndex = Math.min(state.items.length - 1, state.selectedIndex + 1);
if (key.name === 'q') cleanup();
render();
}
function cleanup() {
process.stdin.setRawMode(false);
process.stdout.write(ansiEscapes.cursorShow);
process.stdout.write(ansiEscapes.exitAlternativeScreen);
process.exit(0);
}
process.stdout.write(ansiEscapes.enterAlternativeScreen);
process.stdout.write(ansiEscapes.cursorHide);
process.stdout.write(ansiEscapes.clearScreen);
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', handleKey);
// Re-render on resize
process.stdout.on('resize', render);
render();

Try running this and resizing the terminal window — the layout adapts instantly.

For complex layouts, it is good practice to show an error when the terminal is too small:

function render() {
const cols = process.stdout.columns;
const rows = process.stdout.rows;
// Show "too small" message instead of a broken layout
if (cols < 40 || rows < 10) {
process.stdout.write(ansiEscapes.cursorTo(0, 0));
process.stdout.write(ansiEscapes.clearScreen);
process.stdout.write(chalk.red(`Terminal too small: ${cols}×${rows}\n`));
process.stdout.write(chalk.dim('Minimum: 40×10\n'));
return;
}
// ...normal render
}

If you are using the ScreenBuffer from the previous lesson, call resize() in the resize handler before re-rendering:

process.stdout.on('resize', () => {
buf.resize(process.stdout.columns, process.stdout.rows);
render();
});

The buffer reinitializes its grids, which forces a full repaint on the next flush — necessary since the terminal was cleared by the resize operation.


Module 2 complete. You now have all the building blocks:

  • Raw mode input with key parsing
  • A render loop pattern (state → render on input)
  • Double buffering for flicker-free animation
  • Resize handling

Module 3 uses these to build reusable UI components.