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/styleExports
Style Instance
| Export | Description |
|---|---|
style | Default 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
| Export | Description |
|---|---|
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:
| Export | Description |
|---|---|
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:
| Export | Description |
|---|---|
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 errorDynamic 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 errorA 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.
Hyperlinks (OSC 8)
| Export | Description |
|---|---|
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
| Export | Description |
|---|---|
applyStyle(text, pair) | Apply an ANSI pair with nesting-safe composition |
composeStyles(...pairs) | Compose multiple ANSI pairs into one |
Capability Detection
| Export | Description |
|---|---|
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
| Export | Description |
|---|---|
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
| Export | Description |
|---|---|
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
| Export | Description |
|---|---|
defaultTheme | Default auto-mode markdown theme |
createMarkdownTheme(options?) | Create a theme with optional overrides |
Types
| Type | Description |
|---|---|
AnsiPair | Open/close ANSI escape sequence pair |
ColorMode | "auto" | "always" | "never" |
StyleOptions | Options for createStyle |
CapabilityOverrides | Injectable TTY/NO_COLOR overrides for testing |
TrueColorOverrides | Injectable COLORTERM/TERM overrides for truecolor testing |
HyperlinkOptions | Optional OSC 8 hyperlink parameters such as id |
StyleFn | (text: string) => string style function |
StyleInstance | Configured style object with all styling methods. Exposes enabled, colorsEnabled, trueColorEnabled, and colorDepth capability flags |
ColorDepth | "truecolor" | "256" | "16" | "none" |
WrapOptions | Options for wrapText |
ColumnAlignment | "left" | "right" | "center" |
TableOptions | Options for table |
UnorderedListOptions | Options for unorderedList |
OrderedListOptions | Options for orderedList |
TaskListItem | { text: string; checked: boolean } |
TaskListOptions | Options for taskList |
MarkdownTheme | Semantic theme contract with 30 GFM slots |
PartialMarkdownTheme | Partial override type for theme customization |
ThemeSlotFn | (value: string) => string theme slot function |
CreateMarkdownThemeOptions | Options 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.isTTYandNO_COLOR NO_COLORdisables color only when it is present and non-empty- Modifier helpers such as
bold()anditalic()are not disabled byNO_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`); // nested3. 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 depth | Output | Detection (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 unchanged | Not 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 styleInvalid 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.
Hyperlinks (OSC 8)
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:
- Styling Core — ANSI code mapping, nesting-safe application, and composition
- Capability Layer — Runtime color mode resolution (
auto/always/never) with truecolor detection - Layout Layer — ANSI-aware width, wrapping, padding, and block formatting
- 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.