ANSI escape codes in Node.js — CLI tools, isTTY, chalk, picocolors
Node-CLI surfaces where `/use/javascript` only sketches: `process.stdout.isTTY` for capability detection (false when piped, redirected, or running under CI), `process.stdout.columns` for adaptive width, the ESM-only chalk 5+ break that bites every CommonJS project, and the `node:tty` + `readline` ANSI key-decoding primitives. If you're shipping a CLI binary (npx, `bin/` entry, oclif, commander), this is the page; if you're picking a colour library for a Next.js / Vite app, the JavaScript page is more general.
Recommended libraries
- chalk
Chainable colour API: `chalk.bold.red('oops')`. Auto-detects 16 / 256 / 16M support and honours `NO_COLOR` + `FORCE_COLOR` out of the box. **v5+ is ESM-only** — CommonJS callers must use `await import('chalk')` or stay on chalk 4. Most popular Node colour library by a wide margin.
- picocolors
~100 LOC, zero dependencies, CommonJS-and-ESM dual entry. The cold-start winner — used by Vite, Vue, Tailwind, PostCSS exactly because it doesn't drag in the chalk-shaped supports-color / ansi-styles graph.
- kleur
Drop-in chalk-shaped API, CommonJS by default, ~5x faster than chalk on micro-benchmarks. Useful when you want the chainable ergonomics but need to stay on CJS or care about startup overhead.
- ansi-escapes
Constants + helpers for non-SGR escapes: cursor moves, clear lines, alt screen, OSC 8 hyperlinks, OSC 52 clipboard, iTerm2 imgcat. Pair with chalk / picocolors which only cover SGR.
Idiomatic patterns
// The canonical Node-CLI colour decision. Run this once at startup,
// cache the result, and gate every SGR emission through it. The four
// signals stack in this priority order (highest first):
// 1. --no-color CLI flag (explicit user intent, beats everything)
// 2. FORCE_COLOR=0 (env-level kill switch)
// 3. NO_COLOR=anything (the no-color.org standard — presence wins)
// 4. !process.stdout.isTTY (piped / redirected / CI / daemon — assume false)
// 5. FORCE_COLOR=1|2|3 (force colour even when not a TTY)
function shouldUseColor() {
if (process.argv.includes('--no-color')) return false;
if (process.env.FORCE_COLOR === '0') return false;
if (process.env.NO_COLOR !== undefined) return false;
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') return true;
return Boolean(process.stdout.isTTY);
}
const USE_COLOR = shouldUseColor();
const paint = (sgr, text) =>
USE_COLOR ? `\x1b[${sgr}m${text}\x1b[0m` : text;
console.log(paint('1;31', 'error:'), 'permission denied');// chalk 5+ is ESM-only. `require('chalk')` throws ERR_REQUIRE_ESM.
// In a CJS project (package.json without "type": "module") use dynamic
// import — Node has supported async `import()` in CJS since v12.
// CommonJS module
async function main() {
const { default: chalk } = await import('chalk');
console.log(chalk.bold.red('error:'), 'permission denied');
}
main();
// Alternatively, pin chalk 4 (`"chalk": "^4.1.2"`) which is the last
// CJS-compatible release. Same API surface for the chainable colours.// Render a progress bar / table that re-flows when the terminal resizes.
// process.stdout.columns is undefined when stdout is not a TTY — fall back
// to 80 (the POSIX default) so piped output still produces something sane.
function getWidth() {
return process.stdout.columns ?? 80;
}
function renderBar(pct) {
const w = Math.max(10, getWidth() - 8); // 8 cells for "100% [" + "]"
const filled = Math.round((w * pct) / 100);
return `${String(pct).padStart(3)}% [${'█'.repeat(filled)}${' '.repeat(w - filled)}]`;
}
process.stdout.on('resize', () => {
process.stdout.write('\r\x1b[2K' + renderBar(42));
});
process.stdout.write(renderBar(42));// readline emits decoded keypress events — Node parses CSI / SS3 sequences
// for you and gives you a normalized {name, ctrl, meta, shift} object.
import readline from 'node:readline';
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.on('keypress', (str, key) => {
if (key.ctrl && key.name === 'c') process.exit();
console.log({ str, name: key.name, ctrl: key.ctrl, meta: key.meta, shift: key.shift });
// Arrow keys arrive as { name: 'up' / 'down' / 'left' / 'right' }
// Function keys as { name: 'f1' ... 'f12' }
// CSI u (kitty keyboard protocol) is NOT parsed — raw bytes only.
});