Crust logoCrust

Style

Terminal styling, layout utilities, and markdown theme for CLI output.

The style package provides ANSI-safe styling primitives, terminal capability awareness, text layout helpers, and a semantic markdown theme. It has zero runtime dependencies.

Install

bun add @crustjs/style

Exports

Style Instance

ExportDescription
styleDefault auto-mode style instance
createStyle(options?)Create a configured style instance
setGlobalColorMode(mode)Override runtime color output for the default exports
getGlobalColorMode()Read the current runtime color override

Modifiers

ExportDescription
bold(text)Bold text
dim(text)Dimmed text
italic(text)Italic text
underline(text)Underlined text
inverse(text)Inverted foreground/background
hidden(text)Hidden text
strikethrough(text)Strikethrough text

Colors

Foreground: black, red, green, yellow, blue, magenta, cyan, white, gray, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite

Background: bgBlack, bgRed, bgGreen, bgYellow, bgBlue, bgMagenta, bgCyan, bgWhite, bgBrightBlack, bgBrightRed, bgBrightGreen, bgBrightYellow, bgBrightBlue, bgBrightMagenta, bgBrightCyan, bgBrightWhite

Dynamic Colors

A single fg / bg pair powered by Bun.color(). Accepts every input Bun.color understands — hex, named CSS colors, rgb() / rgba(), hsl() / hsla(), lab(), numeric (0xff0000), { r, g, b, a? } objects, and [r, g, b] / [r, g, b, a] arrays. Output adapts to the resolved ColorDepth:

ExportDescription
fg(text, input)Apply a foreground color from any ColorInput
bg(text, input)Apply a background color from any ColorInput

ANSI pair factories for composition:

ExportDescription
fgCode(input)Create a foreground AnsiPair from a ColorInput
bgCode(input)Create a background AnsiPair from a ColorInput

ColorInput

type ColorInput =
  | ColorString
  | number
  | readonly [r: number, g: number, b: number]
  | readonly [r: number, g: number, b: number, a: number]
  | { r: number; g: number; b: number; a?: number };

type ColorString = NamedColor | `#${string}` | (string & {});
type NamedColor = "aliceblue" | "antiquewhite" | /* …146 more… */ | "yellowgreen";

The string branch uses the standard string & {} autocomplete pattern so editors complete the 148 CSS named colors and the # hex prefix while still accepting any other string Bun's CSS parser understands (rgb(), hsl(), lab(), oklch(), …). NamedColor is re-exported from @crustjs/style if you need it in your own helpers.

Strict inline literals

fg, bg, fgCode, and bgCode (plus their style.fg / style.bg / chain .fg / .bg counterparts) check inline string literals against a stricter StrictColorString subset — named CSS colors, #rrggbb / #rgb / #rrggbbaa hex, and CSS color-function notation (rgb(), rgba(), hsl(), hsla(), hwb(), lab(), lch(), oklab(), oklch(), color(), color-mix()). Typos and arbitrary literals fail at compile time:

fg("ok", "rebeccapurple"); // ✅ valid named color
fg("ok", "#ff0000"); // ✅ valid hex
fg("ok", "oklch(60% 0.2 240)"); // ✅ valid CSS function
fg("bad", "rebbecapurple"); // ❌ compile error
fg("bad", "not-a-color"); // ❌ compile error

Dynamic string / ColorString / ColorInput values keep flowing through unchanged for theme tokens loaded from JSON, environment variables, or arbitrary user input — the strict check only fires on inline literals. Template-literal types validate the shape only; structurally-valid-looking but semantically-bogus literals like "#" or "rgb(banana)" still type-check and raise TypeError at runtime via Bun.color(). The helper types StrictColorString, CssColorFunctionString, NonStringColorInput, ColorInputCandidate, and CheckedColorInput<T> are exported from @crustjs/style for users who want to build their own strict wrappers.

Building your own strict wrappers

To relay the same strict checking through a wrapper, parameterise it with CheckedColorInput<T>:

import { fg, type CheckedColorInput, type ColorInputCandidate } from "@crustjs/style";

function paint<const T extends ColorInputCandidate>(input: CheckedColorInput<T>) {
  return fg("x", input);
}

paint("rebeccapurple"); // ✅
paint("rebbecapurple"); // ❌ compile error

A naive wrapper like <T extends NamedColor>(c: T) => fg("x", c) will not compile because T is generic at the call site — use the CheckedColorInput<T> pattern above, or widen the wrapper parameter to string / ColorInput.

ExportDescription
link(text, url, options?)Wrap text in OSC 8 hyperlink escape sequences
linkCode(url, options?)Create an OSC 8 open/close pair for composition

Style Engine

ExportDescription
applyStyle(text, pair)Apply an ANSI pair with nesting-safe composition
composeStyles(...pairs)Compose multiple ANSI pairs into one

Capability Detection

ExportDescription
resolveColorDepth(mode, overrides?)Resolve the ColorDepth tier ("truecolor" | "256" | "16" | "none"). Compare against "none" for a yes/no "is color enabled" check, or "truecolor" to gate 24-bit output.

Text Utilities

ExportDescription
visibleWidth(text)Compute visible width (ANSI-aware, CJK-aware)
wrapText(text, width, options?)Wrap text by visible width with style continuity
padStart(text, width, char?)Left-pad to visible width
padEnd(text, width, char?)Right-pad to visible width
center(text, width, char?)Center-align to visible width

To strip ANSI escape sequences from a string, use Bun's built-in Bun.stripANSI(text) function.

Block Helpers

ExportDescription
unorderedList(items, options?)Format an unordered list with bullet markers
orderedList(items, options?)Format an ordered list with number markers
taskList(items, options?)Format a task list with check markers
table(headers, rows, options?)Format a column-aligned table

Markdown Theme

ExportDescription
defaultThemeDefault auto-mode markdown theme
createMarkdownTheme(options?)Create a theme with optional overrides

Types

TypeDescription
AnsiPairOpen/close ANSI escape sequence pair
ColorMode"auto" | "always" | "never"
StyleOptionsOptions for createStyle
CapabilityOverridesInjectable TTY/NO_COLOR overrides for testing
TrueColorOverridesInjectable COLORTERM/TERM overrides for truecolor testing
HyperlinkOptionsOptional OSC 8 hyperlink parameters such as id
StyleFn(text: string) => string style function
StyleInstanceConfigured style object with all styling methods. Exposes enabled, colorsEnabled, trueColorEnabled, and colorDepth capability flags
ColorDepth"truecolor" | "256" | "16" | "none"
WrapOptionsOptions for wrapText
ColumnAlignment"left" | "right" | "center"
TableOptionsOptions for table
UnorderedListOptionsOptions for unorderedList
OrderedListOptionsOptions for orderedList
TaskListItem{ text: string; checked: boolean }
TaskListOptionsOptions for taskList
MarkdownThemeSemantic theme contract with 30 GFM slots
PartialMarkdownThemePartial override type for theme customization
ThemeSlotFn(value: string) => string theme slot function
CreateMarkdownThemeOptionsOptions for createMarkdownTheme

Usage

Basic Styling

import { bold, red, italic, style } from "@crustjs/style";

console.log(bold("Build succeeded"));
console.log(red("Error: missing argument"));
console.log(italic("note: check your config"));
console.log(style.bold.red("Critical failure"));

By default:

  • Color helpers respect stdout.isTTY and NO_COLOR
  • NO_COLOR disables color only when it is present and non-empty
  • Modifier helpers such as bold() and italic() are not disabled by NO_COLOR

Mode-Aware Styling

import { createStyle } from "@crustjs/style";

// Auto-detect terminal capabilities (default)
const s = createStyle();
console.log(s.bold("hello"));

// Force colors on
const color = createStyle({ mode: "always" });
console.log(color.red("always red"));
console.log(color.bold.red("always bold red"));

// Plain text output
const plain = createStyle({ mode: "never" });
console.log(plain.red("error")); // "error" (no ANSI)

Runtime Color Overrides

The default style export and all named helpers (red, bold, etc.) re-resolve their color mode on every call, so you can override it globally at runtime with setGlobalColorMode / getGlobalColorMode:

import { getGlobalColorMode, setGlobalColorMode, style } from "@crustjs/style";

// Force colors on
setGlobalColorMode("always");
console.log(style.red("always red"));

// Disable colors but keep bold / italic / underline / hyperlinks (no-color.org)
setGlobalColorMode("never");
console.log(style.bold.red("bold, but no red"));

// Revert to auto (respect TTY + NO_COLOR)
setGlobalColorMode(undefined);

// Read the current override (ColorMode | undefined)
getGlobalColorMode();

Instances returned by createStyle() capture their mode at creation time and are not affected by setGlobalColorMode. The override only applies to the default style facade and the named re-exports.

For a scoped override that only lasts for a single CLI run, use noColorPlugin().

Composing Styles

Three composition shapes — pick whichever fits the call site.

1. Chainable getters

import { style } from "@crustjs/style";

console.log(style.bold.red("critical error"));
console.log(style.italic.brightCyan.bgYellow("highlight"));

2. Tagged template literals

const ms = 42;
console.log(style.bold.red`Build in ${ms}ms`);
console.log(style.bold`Build ${style.cyan`./dist`} in ${ms}ms`); // nested

3. AnsiPair composition

Every chainable doubles as an AnsiPair (open / close properties), so it can be passed to composeStyles and applyStyle directly. The pre-built *Code exports are still available for callers that prefer pure data:

import { applyStyle, composeStyles, bold, red, bgYellow, fgCode } from "@crustjs/style";

const danger = composeStyles(bold, red, bgYellow);
console.log(applyStyle("DANGER", danger));

// Dynamic colors compose too
const brand = composeStyles(bold, fgCode("#4FA83D"));
console.log(applyStyle("Crust", brand));

Dynamic colors in chains

style.bold.fg("#ff8800")("warning");
style.fg("rebeccapurple").italic.underline("emphasis");

chain.open + text + chain.close matches chain(text) byte-for-byte only when adjacent chain steps have distinct close codes. When two adjacent steps share a close code (e.g. bold and dim both close with \x1b[22m), applyStyle re-opens the outer style after each shared close to prevent bleed; that re-open is part of chain(text) but not of the cached chain.close. Use chain(text) for emission and reserve chain.open / chain.close for composeStyles / applyStyle.

Dynamic Colors

Use any color Bun.color() understands — hex (3/6/8 digit), named CSS colors ("red", "rebeccapurple"), rgb() / rgba(), hsl() / hsla(), lab(), numeric literals, { r, g, b, a? } objects, and [r, g, b] / [r, g, b, a] arrays. Output adapts to the terminal's resolved color depth (truecolor, 256-color, 16-color, or none) — see Color Depth & Auto-Fallback.

import { fg, bg } from "@crustjs/style";

// Hex (3-, 6-, or 8-digit)
console.log(fg("error", "#ff0000"));
console.log(bg("highlight", "#ff8800"));

// Named CSS colors
console.log(fg("royal", "rebeccapurple"));

// rgb() / hsl() strings
console.log(fg("ocean", "rgb(0, 128, 255)"));
console.log(bg("sun", "hsl(45, 100%, 50%)"));

// Numeric literal
console.log(fg("red", 0xff0000));

// Tuple or object
console.log(fg("coral", [255, 127, 80]));
console.log(bg("slate", { r: 100, g: 116, b: 139 }));

Pair factories work with applyStyle and composeStyles:

import { fgCode, bgCode, applyStyle, composeStyles, boldCode } from "@crustjs/style";

const coral = fgCode("#ff7f50");
console.log(applyStyle("coral text", coral));

const boldCoral = composeStyles(boldCode, fgCode([255, 127, 80]));
console.log(applyStyle("bold coral", boldCoral));

On createStyle instances, dynamic colors respect mode and the resolved color depth:

import { createStyle } from "@crustjs/style";

const s = createStyle({ mode: "always" });
console.log(s.fg("text", "#ff0000"));
console.log(s.bg("text", "rebeccapurple"));

In "auto" mode, fg and bg automatically pick the best Bun.color() format the terminal supports — see Color Depth & Auto-Fallback below. Standard 16-color methods continue to work normally at every depth.

The earlier rgb / bgRgb / hex / bgHex helpers (and their *Code pair-factory variants, plus matching style.* instance methods) still ship and behave exactly as before, but are marked @deprecated and will be removed in v1.0.0. IDEs and tsc surface the deprecation with a one-line replacement hint at every call site — prefer fg / bg for new code.

Color Depth & Auto-Fallback

fg / bg are capability-aware: the resolved color depth determines which Bun.color() format is emitted. The standalone exports re-resolve on every call (so setGlobalColorMode and NO_COLOR take effect immediately), while instances created with createStyle() capture the depth at construction time.

Resolved depthOutputDetection (in "auto" mode)
"truecolor"Bun.color(input, "ansi-16m")COLORTERM=truecolor|24bit, or TERM contains truecolor/24bit/ends with -direct
"256"Bun.color(input, "ansi-256")TERM contains 256color
"16"In-package RGB → 16-color quantizer (\x1b[3X/9Xm fg, \x1b[4X/10Xm bg)Any other TTY value
"none"text returned unchangedNot a TTY, NO_COLOR=1, TERM=dumb, or mode === "never"

The "16" row uses an in-package quantizer (same algorithm as ansi-styles / chalk) pending an upstream Bun fix (oven-sh/bun#22161).

Detection follows the existing NO_COLOR / COLORTERM / TERM conventions — no new environment variables are introduced. Disable color emission entirely with setGlobalColorMode("never") or NO_COLOR=1; force truecolor with setGlobalColorMode("always").

Use resolveColorDepth(mode, overrides?) to inspect the resolved tier directly, or read style.colorDepth / instance.colorDepth:

import { resolveColorDepth, style } from "@crustjs/style";

resolveColorDepth("auto"); // "truecolor" | "256" | "16" | "none"
style.colorDepth; // depth currently used by the runtime style

Invalid inline color literals (e.g. fg("hello", "not-a-color")) are caught at compile time under TypeScript via the strict inline-literal check above. Inputs that bypass the type check — dynamic strings, JS callers, or as string casts — still raise TypeError (Invalid color input: ...) at every depth (including "none") and regardless of whether text is empty. fg("", c as string) with a bogus color still throws, so callers can't silently mask bugs.

Nullish text (e.g. fg(undefined, "#f00"), bold(null)) returns "" defensively. This protects JS callers that bypass TypeScript types from crashes.

import { createStyle, linkCode, composeStyles, applyStyle, underlineCode } from "@crustjs/style";

const s = createStyle({ mode: "always" });

console.log(s.link("Crust docs", "https://crustjs.com"));
console.log(s.link("API reference", "https://crustjs.com/docs", { id: "docs-link" }));

const underlinedLink = composeStyles(linkCode("https://crustjs.com"), underlineCode);
console.log(applyStyle("Visit Crust", underlinedLink));

In "auto" mode, hyperlinks are emitted when stdout is a TTY. They are not disabled by NO_COLOR, since OSC 8 links are not color sequences. In "never" mode, link helpers return plain text.

Terminal support for OSC 8 varies by emulator. Many modern terminals support it, but there is no reliable cross-terminal capability probe yet, so unsupported terminals may simply show plain text without clickable behavior.

Text Layout

import { visibleWidth, wrapText, padEnd, table } from "@crustjs/style";

// Measure and wrap styled text
const width = visibleWidth(styledText);
const wrapped = wrapText(longText, 60);

// Formatted table output
const output = table(
  ["Command", "Description"],
  [
    ["build", "Build all packages"],
    ["test", "Run test suite"],
  ],
);
console.log(output);

Markdown Theme

import { defaultTheme, createMarkdownTheme } from "@crustjs/style";

// Use the default theme
console.log(defaultTheme.heading1("Getting Started"));
console.log(defaultTheme.strong("important"));
console.log(defaultTheme.inlineCode("bun install"));

// Customize specific slots
const custom = createMarkdownTheme({
  style: { mode: "always" },
  overrides: {
    heading1: (text) => `# ${text.toUpperCase()}`,
  },
});

The markdown theme controls how links look, but it does not create OSC 8 hyperlinks by itself. When your renderer has both the link label and destination URL, use style.link() or linkCode() to emit a clickable link.

Design

@crustjs/style is the presentation layer for Crust terminal output. It is parser-agnostic — markdown parsing, AST transformation, and syntax highlighting belong to separate consumer packages. This package provides the styling and layout primitives that those packages build on.

The architecture follows four layers:

  1. Styling Core — ANSI code mapping, nesting-safe application, and composition
  2. Capability Layer — Runtime color mode resolution (auto/always/never) with truecolor detection
  3. Layout Layer — ANSI-aware width, wrapping, padding, and block formatting
  4. Theme Layer — Semantic style slots for GFM constructs

The NO_COLOR environment variable and non-TTY detection are respected in auto mode for colors. NO_COLOR="" does not disable color, and non-color modifiers remain available even when NO_COLOR=1.

On this page