After a few months of development, most CLIs using Rich have accumulated a colour palette nobody chose: [red] in one command and [bold red] in another for the same kind of error, [green] successes next to [bright_green] ones, a dim grey that is unreadable on light terminal themes, blue text that vanishes on dark ones, and a check mark in some success messages but not others. Users stop being able to read the output at a glance, because the same colour means different things in different places. The fix is the same one web developers use: semantic styles. Define what success, warning, error and muted look like once, in a Rich Theme; write [error] instead of [red] everywhere; and route messages through a tiny helper so the format of each kind of message is consistent too. This guide builds that, chooses a palette that survives light and dark themes, lets users override it from their config, and tests it. It belongs to the interactive terminal UI with Rich topic.
Prerequisites
- Rich 13+ (included with Typer) and Python 3.11+ for
tomllib. - A CLI with more than a couple of commands producing styled output.
Semantic styles, not colours
A Rich Theme maps style names to style definitions. Once a console has the theme, markup like [error]...[/] uses the named style. The code now says what a piece of text means — this is an error, this is secondary detail, this is a path — and the theme decides what that looks like. Changing the error colour becomes a one-line edit, users can override styles in their own config, and every command automatically agrees with every other.
The recipe
# src/mytool/theme.py
from __future__ import annotations
import tomllib
from pathlib import Path
from rich.console import Console
from rich.errors import StyleSyntaxError
from rich.style import Style
from rich.theme import Theme
DEFAULT_STYLES: dict[str, str] = {
"success": "green",
"warning": "yellow",
"error": "bold red",
"muted": "dim",
"heading": "bold",
"code": "cyan",
"path": "underline",
}
SYMBOLS = {"success": "✓", "warning": "!", "error": "✗"}
def load_theme(user_file: Path | None = None) -> Theme:
"""Defaults, overridden per key by an optional [theme] table in the user's config."""
styles = dict(DEFAULT_STYLES)
if user_file and user_file.is_file():
with user_file.open("rb") as fh:
overrides = tomllib.load(fh).get("theme", {})
for name, value in overrides.items():
if name not in DEFAULT_STYLES:
raise ValueError(f"unknown style {name!r}; known: {', '.join(DEFAULT_STYLES)}")
try:
Style.parse(value)
except StyleSyntaxError as exc:
raise ValueError(f"invalid style for {name!r}: {exc}") from None
styles[name] = value
return Theme(styles, inherit=True)
def make_console(theme: Theme, *, stderr: bool = False, **kwargs) -> Console:
return Console(theme=theme, stderr=stderr, highlight=False, **kwargs)
class Out:
"""The only way commands produce styled messages."""
def __init__(self, console: Console) -> None:
self.console = console
def success(self, text: str) -> None:
self.console.print(f"[success]{SYMBOLS['success']}[/] {text}")
def warning(self, text: str) -> None:
self.console.print(f"[warning]{SYMBOLS['warning']}[/] {text}")
def error(self, text: str, hint: str | None = None) -> None:
self.console.print(f"[error]{SYMBOLS['error']} {text}[/]")
if hint:
self.console.print(f" [muted]hint:[/] {hint}")
def detail(self, text: str) -> None:
self.console.print(f" [muted]{text}[/]")
In a Typer app, build the theme and console once in the callback and share them:
import typer
from mytool.theme import Out, load_theme, make_console
from mytool.paths import CONFIG_FILE # e.g. from platformdirs
app = typer.Typer()
@app.callback()
def main(ctx: typer.Context) -> None:
theme = load_theme(CONFIG_FILE)
ctx.obj = {"out": Out(make_console(theme)), "err": Out(make_console(theme, stderr=True))}
@app.command()
def deploy(ctx: typer.Context, site: str) -> None:
out, err = ctx.obj["out"], ctx.obj["err"]
out.success(f"deployed {site} to [path]prod[/]")
err.warning("2 files over 1 MB")
err.detail("uploaded 14 files in 3.2s")
The pieces
DEFAULT_STYLES is the whole visual language in seven lines. inherit=True keeps Rich's built-in styles (used by tables, tracebacks and syntax highlighting) available alongside yours.
Out is the only way commands produce status messages. It fixes not just colour but shape: every success starts with ✓, every error with ✗, hints are indented and muted. Consistency of shape is what lets people scan output quickly — they learn once that ✗ lines matter.
Symbols and words carry the meaning. Colour reinforces them. With NO_COLOR, a colour-blind user, or output piped to a file, "✗ deploy failed" still says everything; see respecting NO_COLOR and FORCE_COLOR.
highlight=False stops Rich's automatic highlighter from colouring numbers and strings inside your messages in ways your theme did not choose.
User overrides are validated. A [theme] table in the user's config can change any style; unknown names and invalid style strings are rejected with a clear message rather than failing later in the middle of output.
A palette that survives every terminal theme
Users run light themes, dark themes, high-contrast themes and custom palettes. Two rules keep text readable on all of them:
- Use the named ANSI colours —
red,green,yellow,cyan— for semantic styles. Terminal themes remap these sixteen colours to shades that suit their background, sogreenis readable on both light and dark. Exact RGB values (#777777,rgb(0,0,255)) are rendered literally and disappear on one theme or the other. - Add a second cue for important styles.
bold redfor errors is still distinguishable in a theme with a muted red;dimfor secondary text adapts to the theme's foreground instead of choosing a grey.
Test the palette once in a light and a dark theme — and once with NO_COLOR=1 — before settling on it.
UX considerations
- Few styles, used strictly. Seven semantic styles cover almost every CLI. More styles means more meanings users must learn.
- Style only human output. Machine-readable output (
--json, CSV) is never styled, whatever the theme; see emitting JSON output for scripting. - Consistent across tables too. Use theme names in table cells (
"[error]failed[/]") and columnstyle=arguments, so tables and messages agree — as in rendering tables and JSON with Rich. - Document the override. A sentence in the docs showing the
[theme]table lets users with unusual palettes fix readability themselves.
Testing the behaviour
Render into a StringIO console to test both the plain text (the meaning) and, with a forced terminal and a fixed colour system, that styles really come from the theme:
# tests/test_theme.py
import io
import pytest
from rich.console import Console
from mytool.theme import DEFAULT_STYLES, Out, load_theme, make_console
def render(out_fn, theme=None, **console_kw) -> str:
console = make_console(theme or load_theme(), file=io.StringIO(), width=80, **console_kw)
out_fn(Out(console))
return console.file.getvalue()
def test_plain_output_keeps_meaning_without_colour():
text = render(lambda o: o.error("deploy failed", hint="run mytool auth login"), color_system=None)
assert text == "✗ deploy failed\n hint: run mytool auth login\n"
def test_styles_come_from_the_theme():
text = render(lambda o: o.success("deployed"), force_terminal=True, color_system="standard")
assert "\x1b[32m" in text # green, from the 'success' style
def test_user_override(tmp_path):
cfg = tmp_path / "config.toml"
cfg.write_text('[theme]\nsuccess = "bold blue"\n')
theme = load_theme(cfg)
assert str(theme.styles["success"]) == "bold blue"
@pytest.mark.parametrize("toml, message", [
('[theme]\nsucess = "green"\n', "unknown style"),
('[theme]\nerror = "not-a-colour"\n', "invalid style"),
])
def test_bad_overrides_are_explained(tmp_path, toml, message):
cfg = tmp_path / "config.toml"
cfg.write_text(toml)
with pytest.raises(ValueError, match=message):
load_theme(cfg)
def test_every_semantic_style_parses():
theme = load_theme()
for name in DEFAULT_STYLES:
assert name in theme.styles
The first test is the most valuable: with colour disabled, the exact text is still complete and meaningful. A small lint helps too — a test or pre-commit hook that greps the source for raw colour markup such as [red] outside theme.py keeps new code on the semantic names.
Conclusion
Consistent output comes from naming meanings, not colours. Define a handful of semantic styles in a Rich Theme, route messages through a small helper that fixes their shape and symbols, choose named ANSI colours with a second cue for the important ones, keep machine output unstyled, and let users override styles from their config with validation. Every command then speaks the same visual language, on every terminal theme.
Frequently asked questions
Can Typer's own help and error panels use my theme?
Typer's help formatting uses its own Rich styles, configurable through module-level settings in typer.rich_utils. Matching them to your theme is possible but rarely worth it; users expect help to look like help.
Should I support light and dark theme variants?
Usually not needed if you stick to named ANSI colours, which the terminal adapts for you. If you use RGB colours for branding, provide two themes and let users choose with a config setting or an environment variable.
How do I style log output consistently?
Rich's RichHandler renders log levels with styles named logging.level.warning and so on; add those names to your theme to align log colours with message colours. The handler itself is covered in structured logging for CLI apps.
Where should the theme live in the codebase?
In one small module next to the command layer, as theme.py above, imported only by code that produces human output. Core logic should never import it — core returns data and raises errors, and the command layer decides how they look. That separation is what lets the same core serve a --json mode, a TUI and tests without any styling concerns.
Are emoji a good fit for status symbols?
Plain symbols (✓ ✗ !) render at a predictable width in nearly every font; emoji vary in width and may show as boxes on servers and older consoles. Use symbols, and provide ASCII fallbacks when the output encoding cannot represent them.