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.
Box Anatomy
Section titled “Box Anatomy”col,row ┌──────────────────────┐ ← top border │ Title │ ← title row (optional) ├──────────────────────┤ ← title separator (optional) │ content line 1 │ ← content │ content line 2 │ ← content └──────────────────────┘ ← bottom border ↑ ↑left border right borderA box needs: position (col, row), size (width, height), optional title, border style, and content.
Border Styles
Section titled “Border Styles”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:' ' },};The drawBox Function
Section titled “The drawBox Function”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));}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 boxdrawBox({ col: 2, row: 2, width: 30, height: 5, title: 'Single', borderStyle: 'single', borderColor: chalk.cyan, content: ['Line 1', 'Line 2', 'Line 3'],});
// Double-line boxdrawBox({ col: 35, row: 2, width: 30, height: 5, title: 'Double', borderStyle: 'double', borderColor: chalk.yellow, content: ['Alpha', 'Beta', 'Gamma'],});
// Rounded boxdrawBox({ col: 2, row: 10, width: 30, height: 5, title: 'Rounded', borderStyle: 'rounded', borderColor: chalk.green, content: ['Round corners!', '', 'Cozy.'],});
// Bold boxdrawBox({ 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); }});Box Inside Box (Panels)
Section titled “Box Inside Box (Panels)”Complex TUIs nest boxes. There is nothing special to it — just call drawBox multiple times with positions calculated relative to the outer box:
// Outer paneldrawBox({ 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.