Color Design
Let me save you man-years of effort with three simple suggestions.
1. ANSI Safe
This table lists safe combinations of ANSI colors. "Safe" means that it is easy to make it look good. Notice that I avoid ANSI 16 (and ANSI 256 colors 0-15 which are the same thing). Full explanation at the bottom.
| name | fg | bg | notes |
|---|---|---|---|
| - | - | - | It's normal and expected to use default (also known as "no color") for most output. Use ansi reset to get back to this. |
| fg | ANSI 256 16-255 | - | Pick a text color that works with the terminal theme (light vs dark) |
| fg+bg | ANSI 256 16-255 | ANSI 256 16-255 | You have control over both colors so it'll look good. May clash with terminal theme, though. |
2. ANSI 256
ANSI 256 is totally sufficient for most apps. Avoid colors 0-15, they lie. When I say WHITE, I mean WHITE. Not off white. WHITE.
Feel free to go all the way to ANSI 16M, of course. As your app grows in popularity you will encounter oddball terminals that don't support it, so use a library that auto-downsamples.
BTW, the ANSI 256 Color Cube is pretty neat. It's algorithmic, though there have been attempts to name the colors since this was added in 1999. Thanks Todd!
3. Be Thoughtful about Background
Only set background color when inverting. I usually leave the background color alone. This is the #1 thing you can do to make your app play nicely with the terminal theme. On the other hand, I love drawing attention to headers or important information by inverting colors. On a dark terminal that means true white foreground on a pretty, saturated background color. Vice versa for light terminals.
Special note on downsampling. It's trivial to write a function that takes an RGB color and finds the closest ANSI 256 16-255 color using euclidean distance. Be careful - that approach completely fails to take human vision into account. You might want to read this, or at least ask your llm to consider the problem.
Tools
I built some dedicated tools for messing with color:
- Nearest Color - nearest ANSI 256 and Tailwind matches
- Giant Contrast Table - see lots of colors on light and dark
- Catppuccin Colors - Catppuccin-as-code
- D3 Ordinal Scales - d3 scales-as-code
- Tailwind Colors - tailwind colors-as-code
Avoid ANSI 16 & ANSI 256 0-15
I find this topic confusing so I'm putting extra details down here. All modern terminals let the user customize the "theme", which in effect means customizing ANSI colors 0-16. We are stuck with a primitive system for communicating color between apps and the host terminal. App says "color 4" and the terminal shows the color that the user picked for "blue".
For example, I use the catppuccin/ghostty Ghostty theme, which is setup like this:
# catppuccin-frappe.conf ghostty theme
palette = 0=#51576d
palette = 1=#e78284
palette = 2=#a6d189
palette = 3=#e5c890
palette = 4=#8caaee
palette = 5=#f4b8e4
palette = 6=#81c8be
palette = 7=#a5adce
palette = 8=#626880
palette = 9=#e78284
palette = 10=#a6d189
palette = 11=#e5c890
palette = 12=#8caaee
palette = 13=#f4b8e4
palette = 14=#81c8be
palette = 15=#b5bfe2
...
Eight Normal Colors (Catppuccin Frappe)
| name | rgb | fg16 | bg16 | fg256 | bg256 |
|---|---|---|---|---|---|
| black | #51576d | \e[30m | \e[40m | \e[38;5;0m | \e[48;5;0m |
| red | #e78284 | \e[31m | \e[41m | \e[38;5;1m | \e[48;5;1m |
| green | #a6d189 | \e[32m | \e[42m | \e[38;5;2m | \e[48;5;2m |
| yellow | #e5c890 | \e[33m | \e[43m | \e[38;5;3m | \e[48;5;3m |
| blue | #8caaee | \e[34m | \e[44m | \e[38;5;4m | \e[48;5;4m |
| magenta | #f4b8e4 | \e[35m | \e[45m | \e[38;5;5m | \e[48;5;5m |
| cyan | #81c8be | \e[36m | \e[46m | \e[38;5;6m | \e[48;5;6m |
| white | #a5adce | \e[37m | \e[47m | \e[38;5;7m | \e[48;5;7m |
and of course the anachronistically named "bright" colors are part of the theme:
Eight "Bright" Colors (Catppuccin Frappe)
| name | rgb | fg16 | bg16 | fg256 | bg256 |
|---|---|---|---|---|---|
| black | #626880 | \e[90m | \e[100m | \e[38;5;8m | \e[48;5;8m |
| red | #e78284 | \e[91m | \e[101m | \e[38;5;9m | \e[48;5;9m |
| green | #a6d189 | \e[92m | \e[102m | \e[38;5;10m | \e[48;5;10m |
| yellow | #e5c890 | \e[93m | \e[103m | \e[38;5;11m | \e[48;5;11m |
| blue | #8caaee | \e[94m | \e[104m | \e[38;5;12m | \e[48;5;12m |
| magenta | #f4b8e4 | \e[95m | \e[105m | \e[38;5;13m | \e[48;5;13m |
| cyan | #81c8be | \e[96m | \e[106m | \e[38;5;14m | \e[48;5;14m |
| white | #b5bfe2 | \e[97m | \e[107m | \e[38;5;15m | \e[48;5;15m |
Helpful Illustration
To refer back to the example at the top of this page,
# ansi16 white on ansi16 green (borked)
printf '\e[37;42m 1. hello, world \e[0m\n'
# ansi256 brightwhite on ansi256 green (borked)
printf '\e[38;5;15;48;5;2m 2. hello, world \e[0m\n'
# ansi 256 system white on ansi256 system green (AWESOMENESS)
printf '\e[38;5;255;48;5;40m 3. hello, world \e[0m\n'

When we try to create UI with the user's colors, things can go astray. That's why I avoid ANSI 16 and ANSI 256 0-15. Just because the internet calls that color "white" doesn't mean it's going to look anything like white. Don't be fooled!