ANSI Colored Logger in 50 Lines
chalk is great. chalk plus chalk-template plus log-symbols plus signal-exit is heavy. Here is the dependency-free ANSI logger I drop into every Node script when I want one-line wins without 14 transitive packages.
By @carlosherrera
May 12, 2026
·
Updated May 18, 2026
1,187 views
34
4.2 (11)
// ANSI escape codes are simple. \x1b[31m is red, \x1b[0m is reset.
// process.stdout.isTTY is true when output is a real terminal; when piped to
// a file or another process we drop the codes so log files stay clean.
const color = (() => {
const isTTY = (typeof process !== 'undefined' && process.stdout && process.stdout.isTTY) || false;
const wrap = (open, close) => (s) => isTTY ? `\x1b[${open}m${s}\x1b[${close}m` : String(s);
return {
bold: wrap(1, 22),
dim: wrap(2, 22),
red: wrap(31, 39),
green: wrap(32, 39),
yellow: wrap(33, 39),
blue: wrap(34, 39),
magenta: wrap(35, 39),
cyan: wrap(36, 39),
gray: wrap(90, 39),
};
})();
function stamp() {
const d = new Date();
return d.toISOString().slice(11, 19);
}
const logger = {
info: (...a) => console.log(color.gray(stamp()), color.cyan('info'), ...a),
warn: (...a) => console.log(color.gray(stamp()), color.yellow('warn'), ...a),
error: (...a) => console.error(color.gray(stamp()), color.red('error'), ...a),
ok: (...a) => console.log(color.gray(stamp()), color.green('ok'), ...a),
step: (label, fn) => {
console.log(color.gray(stamp()), color.blue('step'), color.bold(label));
return fn();
},
};
logger.info('starting build');
logger.step('compile', () => {
logger.info('compiling lib/');
logger.warn('deprecated import detected in lib/legacy.js');
});
logger.ok('build complete');
logger.error('but tests failed');Two design choices are doing all the work. First, the colors are wrapped in a closure that checks isTTY once at startup; if you redirect output to a file the codes vanish, which is what every log analysis tool needs. Second, the logger is a plain object with one method per level rather than a class, which means you can destructure const { info, error } = logger if you want a chalk-style import. The step helper is the piece I use most: it tags the start of a logical phase and returns the wrapped function's value, so timing wrappers compose nicely. I have shipped this in every internal CLI; it covers the 80% case in fewer lines than the import statement for chalk.
// Two more 'right defaults' that production CLIs need.
// 1. NO_COLOR=1 environment variable, the cross-tool standard for disabling color.
// 2. LOG_LEVEL=error to silence info/warn at runtime.
function makeLogger() {
const env = (typeof process !== 'undefined' && process.env) || {};
const isTTY = (typeof process !== 'undefined' && process.stdout && process.stdout.isTTY) || false;
const colorOn = isTTY && !env.NO_COLOR;
const level = (env.LOG_LEVEL || 'info').toLowerCase();
const order = { error: 0, warn: 1, info: 2, debug: 3 };
const threshold = order[level] !== undefined ? order[level] : 2;
const wrap = (open, close) => (s) => colorOn ? `\x1b[${open}m${s}\x1b[${close}m` : String(s);
const c = {
red: wrap(31, 39),
yellow: wrap(33, 39),
cyan: wrap(36, 39),
gray: wrap(90, 39),
};
const stamp = () => new Date().toISOString().slice(11, 19);
const at = (lvl) => order[lvl] <= threshold;
return {
debug: (...a) => at('debug') && console.log(c.gray(stamp()), c.gray('debug'), ...a),
info: (...a) => at('info') && console.log(c.gray(stamp()), c.cyan('info'), ...a),
warn: (...a) => at('warn') && console.log(c.gray(stamp()), c.yellow('warn'), ...a),
error: (...a) => at('error') && console.error(c.gray(stamp()), c.red('error'), ...a),
};
}
const logger = makeLogger();
logger.debug('this is suppressed at default level');
logger.info('hello');
logger.warn('something off');
logger.error('oh no');
process.env.NO_COLOR = '1';
const plain = makeLogger();
plain.info('this line has no escape codes');
plain.error('and this one has no red');Honoring NO_COLOR=1 is a one-line check and saves you from a support ticket every six months when someone runs your CLI under a CI system that mangles ANSI codes into garbage. The level filter does the matching trick for LOG_LEVEL: turn the level name into a number, compare to a fixed scale, drop messages above the threshold. The && short-circuit on each method means the suppressed call is essentially free; no string formatting, no console call. I do not implement a silent level here because forcing a logger that always writes to stderr keeps the CI failure-mode capture working; if you really want silence, set the level higher than error.
// One thing chalk does not give you: a progress line that updates in place.
// Use \r (carriage return) to move the cursor to start-of-line, then write
// the new line. \x1b[K clears from cursor to end-of-line so a shorter line
// cleanly overwrites a longer previous one.
function makeProgress(label) {
const isTTY = (typeof process !== 'undefined' && process.stdout && process.stdout.isTTY) || false;
return {
update(message) {
if (!isTTY) {
console.log(`${label}: ${message}`);
return;
}
process.stdout.write('\r\x1b[K' + `${label}: ${message}`);
},
done(finalMessage) {
if (!isTTY) {
console.log(`${label}: ${finalMessage}`);
return;
}
process.stdout.write('\r\x1b[K' + `${label}: ${finalMessage}\n`);
},
};
}
const p = makeProgress('build');
let step = 0;
const phases = ['scanning sources', 'compiling lib/', 'compiling app/', 'linking', 'writing manifest'];
const tick = () => {
p.update(`${phases[step]} (${step + 1}/${phases.length})`);
step += 1;
if (step < phases.length) setTimeout(tick, 80);
else p.done(`completed in ${(phases.length * 0.08).toFixed(2)}s`);
};
tick();Two escape codes do everything: \r returns the cursor to the start of the current line, and \x1b[K clears from there to the end of the line. Together they let you rewrite the same line repeatedly without scroll, which is how every progress bar and spinner in npm packages works under the hood. The non-TTY branch is essential: when output is piped to a file, you do NOT want to overwrite lines (they look like garbage) or even use carriage returns; you want flat appended lines so log scrapers can parse them. A subtle pitfall I keep relearning: if the new line is SHORTER than the previous one, the trailing characters stick around without \x1b[K, producing 'compiling lib/y' when 'compiling lib' replaced 'compiling app/y'.
