ANSI escape codes in Elixir
Elixir ships an `IO.ANSI` module in the standard library — every named SGR and a handful of cursor / screen helpers are functions that return their byte form (`IO.ANSI.red()` returns `"\e[31m"`). The idiomatic path is `IO.ANSI.format/2`, which takes a list mixing atoms (the named escapes) and binaries (your text) and returns an iolist suitable for `IO.puts/1` or `IO.write/1` — e.g. `IO.puts(IO.ANSI.format([:bright, :red, "error: ", :reset, "permission denied"]))`. `IO.ANSI.enabled?/0` is the capability gate baked into stdlib: it honours the `:elixir` app env `:ansi_enabled` (set by `iex` to true on a tty, false on a pipe) plus the `NO_COLOR` env var. For more than ad-hoc prints: **Bunt** (savonarola/bunt) sugars `IO.ANSI` with named colour tags inside the string itself — `Bunt.puts([:red, "error: ", :default_color, "permission denied"])` or the inline form `Bunt.puts("[red]error:[/red] permission denied")`. **Owl** (fuelen/owl) is the modern full-output toolkit (boxes, tables, progress bars, multi-line spinners, ANSI links) — used by Phoenix-generated CLI tasks, `mix` extensions, and `livebook` startup output. For full-screen TUIs reach for **Ratatouille** (ndreynolds/ratatouille) — an Elm-style `model / update / view` architecture rendered to ANSI via termbox2.
Recommended libraries
- IO.ANSI
Stdlib module — `IO.ANSI.red/0`, `IO.ANSI.bright/0`, `IO.ANSI.format/2` (mixes atoms + binaries into iolist), `IO.ANSI.enabled?/0` (capability gate that honours `:elixir`'s `:ansi_enabled` app env + `NO_COLOR`). All escape generators are pure functions returning binaries — zero runtime cost, zero deps.
- Bunt
Tagged-string sugar on top of IO.ANSI — `Bunt.puts("[red]error:[/red] permission denied")` or `Bunt.puts([:red, "error", :reset])`. Adds named colour aliases (`:chartreuse`, `:burlywood3`, etc.) for finer-grained SGR-256 control. Used by `credo`, `ex_doc`, and many `mix` task UIs.
- Owl
Modern full-output toolkit — boxes (`Owl.Box.new/2`), tables (`Owl.Table.new/2`), progress bars (`Owl.ProgressBar`), multi-line spinners, ANSI links (`Owl.Data.tag/2`). Used by Phoenix-generated CLI tasks, `mix livebook.install`, and other recent Elixir-tool UIs.
- Ratatouille
Full-screen TUI framework — Elm-style `model / update / view` architecture rendered to ANSI via the bundled `termbox2` NIF. View functions return tree-of-elements that the runtime diffs and paints. Used for `mix` task dashboards, log viewers, in-house dev tools.
Idiomatic patterns
# Elixir string literals do support \e (= \x1b). All three forms are
# byte-equivalent: \e, \x1b, \u{1b}. IO.puts is byte-clean.
IO.puts("\e[1;31merror:\e[0m permission denied")
IO.puts("\x1b[1;32mok:\x1b[0m all 142 tests passed")IO.puts(IO.ANSI.format([:bright, :red, "error: ", :reset, "permission denied"]))
IO.puts(IO.ANSI.format([:green, "ok: ", :reset, "all 142 tests passed"]))
# IO.ANSI.format/2 takes an optional second arg: emit_codes? boolean.
# Pass IO.ANSI.enabled? so the same call works in pipes / CI / NO_COLOR.
IO.puts(IO.ANSI.format([:red, "error"], IO.ANSI.enabled?()))defmodule MyApp.Color do
# IO.ANSI.enabled? already honours :ansi_enabled + NO_COLOR. Layer
# FORCE_COLOR on top for CI runs that want colour despite no tty.
def enabled? do
cond do
System.get_env("NO_COLOR") -> false
System.get_env("FORCE_COLOR") -> true
true -> IO.ANSI.enabled?()
end
end
def paint(chunks) when is_list(chunks) do
IO.ANSI.format(chunks, enabled?())
end
end
IO.puts(MyApp.Color.paint([:bright, :red, "error: ", :reset, "no perm"]))# Truecolor — IO.ANSI has no helper for SGR \x1b[38;2;r;g;b m, but you
# can splice raw bytes into the format list. Or use Owl, which accepts
# RGB tuples directly.
# Hand-rolled truecolor:
fg_rgb = fn r, g, b -> "\e[38;2;#{r};#{g};#{b}m" end
IO.puts([fg_rgb.(203, 166, 247), "lavender truecolor", IO.ANSI.reset()])
# Owl box with truecolor border:
# {:owl, "~> 0.12"} in mix.exs deps
"Build complete — 142 tests, 0 failures"
|> Owl.Box.new(border_style: :double, title: " status ", padding: 1)
|> Owl.IO.puts()