Skip to content

3.1 Boxes & Borders

Boxes are the fundamental building block of TUI layouts. Almost every panel, dialog, menu, and widget is a box. In this lesson you will build a drawBox function flexible enough to power the rest of the course.

col,row
┌──────────────────────┐ ← top border
│ Title │ ← title row (optional)
├──────────────────────┤ ← title separator (optional)
│ content line 1 │ ← content
│ content line 2 │ ← content
└──────────────────────┘ ← bottom border
↑ ↑
left border right border

A box needs: position (col, row), size (width, height), optional title, border style, and content.

examples/module-3/borders.ts
export type BorderStyle = 'single' | 'double' | 'rounded' | 'bold' | 'none';
export const BORDERS: Record<BorderStyle, {
tl: string; tr: string; bl: string; br: string;
h: string; v: string; ml: string; mr: string;
}> = {
single: { tl:'', tr:'', bl:'', br:'', h:'', v:'', ml:'', mr:'' },
double: { tl:'', tr:'', bl:'', br:'', h:'', v:'', ml:'', mr:'' },
rounded:{ tl:'', tr:'', bl:'', br:'', h:'', v:'', ml:'', mr:'' },
bold: { tl:'', tr:'', bl:'', br:'', h:'', v:'', ml:'', mr:'' },
none: { tl:' ', tr:' ', bl:' ', br:' ', h:' ', v:' ', ml:' ', mr:' ' },
};
examples/module-3/draw-box.ts
import chalk, { ChalkInstance } from 'chalk';
import ansiEscapes from 'ansi-escapes';
import { BORDERS, type BorderStyle } from './borders';
export type BoxOptions = {
col: number; // 1-indexed left edge
row: number; // 1-indexed top edge
width: number; // total width including borders
height: number; // total height including borders
title?: string;
borderStyle?: BorderStyle;
borderColor?: ChalkInstance;
titleColor?: ChalkInstance;
content?: string[]; // pre-formatted lines (no ANSI, plain text)
padding?: number; // horizontal padding inside the box (default 1)
};
export function drawBox(opts: BoxOptions) {
const {
col, row, width, height,
title,
borderStyle = 'single',
borderColor = chalk.white,
titleColor = chalk.bold.white,
content = [],
padding = 1,
} = opts;
const b = BORDERS[borderStyle];
const inner = width - 2; // inner width (excluding border chars)
const pad = ' '.repeat(padding);
const contentWidth = inner - padding * 2;
const line = (text: string) =>
borderColor(b.v) + text + borderColor(b.v);
const move = (c: number, r: number) =>
ansiEscapes.cursorTo(c - 1, r - 1);
// ── top border
process.stdout.write(move(col, row));
if (title) {
const t = title.slice(0, inner - 4);
const leftPad = Math.floor((inner - t.length - 2) / 2);
const rightPad = inner - t.length - 2 - leftPad;
process.stdout.write(
borderColor(b.tl + b.h.repeat(leftPad) + ' ') +
titleColor(t) +
borderColor(' ' + b.h.repeat(rightPad) + b.tr)
);
} else {
process.stdout.write(borderColor(b.tl + b.h.repeat(inner) + b.tr));
}
// ── content rows
const contentRows = height - 2;
for (let i = 0; i < contentRows; i++) {
process.stdout.write(move(col, row + 1 + i));
const text = content[i] ?? '';
const truncated = text.slice(0, contentWidth);
const rightPad = ' '.repeat(Math.max(0, contentWidth - truncated.length));
process.stdout.write(line(pad + truncated + rightPad + pad));
}
// ── bottom border
process.stdout.write(move(col, row + height - 1));
process.stdout.write(borderColor(b.bl + b.h.repeat(inner) + b.br));
}
examples/module-3/01-boxes.ts
import chalk from 'chalk';
import ansiEscapes from 'ansi-escapes';
import { drawBox } from './draw-box';
process.stdout.write(ansiEscapes.enterAlternativeScreen);
process.stdout.write(ansiEscapes.cursorHide);
process.stdout.write(ansiEscapes.clearScreen);
// Single-line box
drawBox({
col: 2, row: 2, width: 30, height: 5,
title: 'Single',
borderStyle: 'single',
borderColor: chalk.cyan,
content: ['Line 1', 'Line 2', 'Line 3'],
});
// Double-line box
drawBox({
col: 35, row: 2, width: 30, height: 5,
title: 'Double',
borderStyle: 'double',
borderColor: chalk.yellow,
content: ['Alpha', 'Beta', 'Gamma'],
});
// Rounded box
drawBox({
col: 2, row: 10, width: 30, height: 5,
title: 'Rounded',
borderStyle: 'rounded',
borderColor: chalk.green,
content: ['Round corners!', '', 'Cozy.'],
});
// Bold box
drawBox({
col: 35, row: 10, width: 30, height: 5,
title: 'Bold',
borderStyle: 'bold',
borderColor: chalk.red,
content: ['Heavy borders', 'For emphasis'],
});
// Status box (no title, custom colors)
drawBox({
col: 2, row: 18, width: 63, height: 4,
borderStyle: 'rounded',
borderColor: chalk.dim,
content: [
chalk.green('') + 'All systems operational',
chalk.dim('Last check: 2026-05-22 09:41:00'),
],
padding: 2,
});
process.stdout.write(ansiEscapes.cursorTo(0, 24));
process.stdout.write(chalk.dim('Press Ctrl+C to exit\n'));
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (k: string) => {
if (k === 'q' || k === '\x03') {
process.stdin.setRawMode(false);
process.stdout.write(ansiEscapes.cursorShow);
process.stdout.write(ansiEscapes.exitAlternativeScreen);
process.exit(0);
}
});

Complex TUIs nest boxes. There is nothing special to it — just call drawBox multiple times with positions calculated relative to the outer box:

// Outer panel
drawBox({ col: 1, row: 1, width: 80, height: 24, title: 'My App', borderStyle: 'double' });
// Inner left panel (sidebar)
drawBox({ col: 2, row: 2, width: 20, height: 22, title: 'Nav', borderStyle: 'single' });
// Inner right panel (content)
drawBox({ col: 23, row: 2, width: 57, height: 22, title: 'Content', borderStyle: 'single' });

Since we always re-render the full screen, there are no z-index issues — the last draw call “wins” at any given cell position.