Skip to main content
ansicode
Node.js (CLI tools)

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

Capability gate — isTTY + NO_COLOR + FORCE_COLOR + --no-color
// 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 in a CommonJS project — dynamic import
// 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.
Adaptive width — process.stdout.columns + resize events
// 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));
Read keys with node:tty + readline — decode ANSI input
// 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.
});

Related sequences

Other languages