Measure terminal cell width from ground truth, not a hardcoded table.
Every other width library treats cell width as a property of the character
(wcwidth + a static table). But it's really a property of this terminal,
right now — emoji, ZWJ sequences, flags, and CJK render at different widths
across terminals, so box borders break when someone pastes an emoji.
termwidth asks the terminal itself: print a grapheme offscreen, fire
CSI 6n, read where the cursor landed. It probes a few hundred ambiguous
graphemes once at startup, caches the result to disk (keyed by terminal +
version), and everything after that is pure arithmetic.
Static libraries (string-width, wcwidth) encode one fixed width per
character. That works for the unambiguous majority — but a whole class of
graphemes has no single correct answer: their width depends on the terminal and
its configuration, not the character. A static table has to pick one guess, and
it's wrong wherever your terminal disagrees. That off-by-one is exactly what
drifts box borders and table columns by a cell.
| Grapheme | Static table | termwidth |
|---|---|---|
→ ① ★ — East Asian Ambiguous |
one hardcoded guess (usually 1) | the width this terminal renders |
🇯🇵 flag (regional indicators) |
one fixed guess | 1 or 2 cells, per terminal |
👍🏽 emoji + skin tone |
one fixed guess | measured per terminal |
👨👩👧 ZWJ family |
one fixed guess | measured per terminal |
a 中 — unambiguous |
correct | correct (both agree) |
termwidth ships a fallback table too, so it's never worse than a static library out of the box — but once the probe lands, the ambiguous rows above switch from guess to your terminal's actual value.
- Zero runtime dependencies. Uses the built-in
Intl.Segmenter. - Synchronous
measure(). Correct from the moment you import (vendored fallback table), sharper once the probe lands. - Safe by design. Raw-mode restore guard on every exit path, per-query timeout, and a silent bail — it never wedges or spams a terminal.
- Drop-in shims for
string-width,wrap-ansi, andcli-truncate.
npm install termwidthRequires Node 16+ (for Intl.Segmenter). Ships dual ESM + CJS.
import { measure, wrap, truncate, ready } from "termwidth";
measure("👨👩👧"); // width from the fallback table immediately
await ready(); // optional: wait for the live probe before first paint
measure("👨👩👧"); // now the terminal's ground-truth widthmeasure() is always safe to call synchronously in a render loop. The probe
fires once on import behind a TTY gate; await ready() only if you want the
sharpened table before your first frame.
Swap one import; the signatures match.
import stringWidth from "termwidth/string-width";
import wrapAnsi from "termwidth/wrap-ansi";
import cliTruncate from "termwidth/cli-truncate";The probe only runs when stdout and stdin are both TTYs, CI is unset, and
TERM isn't dumb. It hides the cursor, batches its CSI 6n queries, and:
- restores raw mode + cursor on
exit,SIGINT,SIGTERM, anduncaughtException; - bails to the vendored table on any per-query or overall timeout;
- validates sentinel graphemes (
a→ 1,中→ 2) and rejects impossible widths, so a mangled stream (e.g. tmux passthrough) is discarded, not cached.
Set TERMWIDTH_DEBUG=1 for diagnostics on stderr (silent otherwise). Set
TERMWIDTH_DISABLE=1 to force the vendored table and skip the probe.
Probed tables live in $XDG_CACHE_HOME/termwidth/<key>.json (or
~/.cache/termwidth/). Probe once per terminal + version, reuse forever.
npx termwidth report # probe this terminal, print canonical JSONMIT
