Typer users get boxed, coloured help screens for free because Typer renders help with Rich. Click users get Click's plain, perfectly serviceable text — until the CLI has thirty options and nobody can find anything. rich-click closes the gap without a rewrite: it is a drop-in layer over Click that formats help and error messages with Rich, adds grouping of options and commands into named panels, and supports Rich markup in help strings. For most applications the change is a single import line. This guide applies it to a Click CLI, configures groups and markup, shows how to try it on a CLI you do not own, and tests the result. It belongs to the help output and documentation topic; the Typer equivalent is Rich markup and help panels in Typer.
Prerequisites
- A Click 8.x CLI.
uv add rich-click(examples checked with rich-click 1.9).
The drop-in
rich-click re-exports Click's API, with its own command and group classes that render help through Rich. Changing the import is enough:
# before
import click
# after
import rich_click as click
Every @click.command(), @click.group() and @click.option() keeps working; help, usage errors and --help output now render as Rich panels with aligned columns, highlighted option names and coloured error boxes on capable terminals.
The recipe
# src/backup/cli.py
import rich_click as click
click.rich_click.TEXT_MARKUP = "rich" # allow [bold], [cyan] in help strings
click.rich_click.SHOW_ARGUMENTS = True # list positional arguments in their own panel
click.rich_click.OPTION_GROUPS = {
"backup": [
{"name": "Destination", "options": ["--to", "--storage-class"]},
{"name": "Filtering", "options": ["--exclude", "--max-size"]},
]
}
@click.command()
@click.argument("sources", nargs=-1, required=True)
@click.option("--to", required=True, help="Local path or [cyan]s3://[/cyan] URL.")
@click.option("--storage-class", type=click.Choice(["STANDARD", "GLACIER"]),
default="STANDARD", show_default=True)
@click.option("--exclude", multiple=True, help="Skip matching paths.")
@click.option("--max-size", type=int, help="Skip files larger than this (MB).")
@click.option("--verbose", "-v", count=True, help="More detail.")
def backup(sources, to, storage_class, exclude, max_size, verbose):
"""Back up [bold]SOURCES[/bold] to local disk or S3."""
click.echo(f"backing up {len(sources)} source(s) to {to}")
Usage: backup [OPTIONS] SOURCES...
Back up SOURCES to local disk or S3.
╭─ Destination ──────────────────────────────────────────────────────────────╮
│ * --to TEXT Local path or s3:// URL. │
│ [required] │
│ --storage-class [STANDARD|GLACIER] [default: STANDARD] │
╰────────────────────────────────────────────────────────────────────────────╯
╭─ Filtering ────────────────────────────────────────────────────────────────╮
│ --exclude TEXT Skip matching paths. │
│ --max-size INTEGER Skip files larger than this (MB). │
╰────────────────────────────────────────────────────────────────────────────╯
╭─ Arguments ────────────────────────────────────────────────────────────────╮
│ * SOURCES TEXT [required] │
╰────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────╮
│ --verbose -v INTEGER RANGE More detail. │
│ --help Show this message and exit. │
╰────────────────────────────────────────────────────────────────────────────╯
OPTION_GROUPS maps a command name to an ordered list of panels; options not listed fall into the default "Options" panel, so --help and rarely used switches end up there naturally. Required options get an asterisk. For groups with subcommands, COMMAND_GROUPS does the same for the list of commands, keyed by the group's name; nested commands are keyed by their full path, such as "mytool sync". The same settings can be passed per command with @click.rich_config(help_config=click.RichHelpConfiguration(...)) if you prefer configuration next to the code it affects.
Errors get the same treatment
Usage errors render in a red-bordered "Error" panel with the usage line above it:
╭─ Error ────────────────────────────────────────────────────────────────────╮
│ Missing option '--to'. │
╰────────────────────────────────────────────────────────────────────────────╯
Exit codes are unchanged — 2 for usage errors — so scripts are unaffected; only the human-facing text changes.
Command groups and Markdown markup
For a group with many subcommands, COMMAND_GROUPS does for commands what OPTION_GROUPS does for options, and TEXT_MARKUP = "markdown" lets docstrings use Markdown instead of Rich tags — handy when the same docstrings also feed generated documentation:
# src/mytool/cli.py
import rich_click as click
click.rich_click.TEXT_MARKUP = "markdown"
click.rich_click.COMMAND_GROUPS = {
"mytool": [
{"name": "Everyday", "commands": ["sync", "status"]},
{"name": "Maintenance", "commands": ["prune", "doctor"]},
]
}
@click.group()
def mytool():
"""Keep **local** and **remote** folders in sync."""
@mytool.command()
def sync():
"""Copy changes in both directions."""
@mytool.command()
def status():
"""Show what would change."""
@mytool.command()
def prune():
"""Delete old snapshots. Uses `--keep` days."""
@mytool.command()
def doctor():
"""Check the environment."""
@mytool.command()
def version():
"""Show the version."""
╭─ Everyday ─────────────────────────────────────────────────────────╮
│ sync Copy changes in both directions. │
│ status Show what would change. │
╰────────────────────────────────────────────────────────────────────╯
╭─ Maintenance ──────────────────────────────────────────────────────╮
│ prune Delete old snapshots. Uses --keep days. │
│ doctor Check the environment. │
╰────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────╮
│ version Show the version. │
╰────────────────────────────────────────────────────────────────────╯
Commands not listed in any group — version here — land in the default "Commands" panel at the end, which is a good place for housekeeping commands. Group names should match how users think about the tool: the everyday verbs first, maintenance and administration after. Keeping the groups in one mapping near the top of the module also gives reviewers a single place to check when a new command is added; a small test that every registered command appears in some group (or deliberately in none) prevents new commands from landing in the catch-all panel by accident.
Matching your house style
rich-click exposes its styles as module-level settings, so help can match the rest of your tool's output — the same colours as the tables and status lines described in theming Rich output consistently:
click.rich_click.STYLE_OPTION = "bold cyan"
click.rich_click.STYLE_COMMAND = "bold cyan"
click.rich_click.MAX_WIDTH = 100 # do not sprawl on very wide terminals
click.rich_click.ERRORS_SUGGESTION = "Try 'mytool --help' for usage."
click.rich_click.ERRORS_EPILOGUE = "Docs: https://example.com/mytool"
MAX_WIDTH is worth setting on its own: help text stretched across a 250-column terminal is harder to read than help capped at a comfortable width. The error suggestion and epilogue appear under every usage error, which makes them the right place for a pointer to documentation or a support channel. Set these once, in the module that defines the root command, so every subcommand inherits them.
Trying it on a CLI you do not own
rich-click installs a rich-click command that runs any Click application with Rich formatting, without modifying its code:
rich-click mytool --help # an installed console script
rich-click mypackage.cli:main --help # module:object
That is a quick way to preview the result on your own CLI before committing to the dependency — or to make a third-party Click tool's help easier to read for yourself.
UX considerations
- Group by task. "Destination", "Filtering", "Output" — the questions users have — rather than by type.
- Keep markup sparse. A highlighted protocol or a bold placeholder helps; whole sentences in colour do not, and colour disappears in logs and pipes anyway.
- Escape square brackets when
TEXT_MARKUP = "rich"and help mentions literal brackets (\[default]), or use"markdown"mode instead. - Mind the dependency weight. rich-click pulls in Rich, which adds import time; most of it is only paid when help or an error is rendered, but measure with profiling Python CLI startup time if startup matters.
- Respect NO_COLOR. Rich honours it automatically, as described in respecting NO_COLOR and FORCE_COLOR.
Testing the behaviour
click.testing.CliRunner works unchanged. Render at a fixed width with colour disabled, and assert on panel titles and their contents:
# tests/test_rich_help.py
import re
from click.testing import CliRunner
from backup.cli import backup
ENV = {"COLUMNS": "78", "NO_COLOR": "1"}
def panels(text: str) -> list[str]:
return re.findall(r"╭─ (.+?) ─", text)
def test_option_groups_render_in_order():
output = CliRunner().invoke(backup, ["--help"], env=ENV).output
assert panels(output) == ["Destination", "Filtering", "Arguments", "Options"]
assert "--storage-class" in output.split("╭─ Destination")[1].split("╰")[0]
def test_markup_is_rendered_not_printed():
output = CliRunner().invoke(backup, ["--help"], env=ENV).output
assert "[cyan]" not in output and "Local path or s3:// URL." in output
def test_usage_errors_keep_exit_code_2():
result = CliRunner().invoke(backup, ["a"], env=ENV)
assert result.exit_code == 2 and "Missing option '--to'" in result.output
Asserting on panels rather than the exact screen keeps tests stable across rich-click releases, which occasionally adjust spacing, while still catching an option that slipped out of its group.
Conclusion
rich-click brings Typer-style help to Click with one changed import: Rich-rendered help and errors, option and command groups configured in one mapping, optional Rich or Markdown markup, and a rich-click runner to preview the result on any Click CLI. Group options by task, keep markup light, escape literal brackets, and test panel structure at a fixed width — exit codes and behaviour stay exactly as they were.
Frequently asked questions
Does rich-click change how arguments are parsed?
No. Parsing, validation, defaults and exit codes are Click's. rich-click replaces only the formatting of help and error messages.
Can I keep plain Click output for some users?
Rich adapts automatically: without a terminal, or with NO_COLOR, colours disappear while the layout remains. If you need Click's exact plain format — for example because downstream tooling parses --help — do not adopt rich-click for that command.
How does it interact with Click plugins and custom classes?
Custom command or group classes should inherit from rich-click's RichCommand and RichGroup instead of Click's base classes; otherwise they fall back to plain formatting. Most Click plugins work unchanged.
Should I switch to Typer instead?
Not just for help formatting — rich-click gives you that with far less change. Switch when you want Typer's type-hint-driven declarations; see converting a Click app to Typer.
Does rich-click work with shell completion and documentation generators?
Completion is Click's and unaffected. Documentation generators that introspect Click commands (such as sphinx-click or mkdocs-click) read the command objects, not the rendered help, so they keep working — though Rich markup tags in help strings may appear literally in generated docs unless the generator understands them.