Declarative terminal styling for Elixir, inspired by Lipgloss.
Esc provides an expressive, composable API for styling terminal output with colors, borders, padding, margins, and alignment. It also includes components for tables, lists, trees, and interactive select menus.
import Esc
style()
|> bold()
|> foreground("#FAFAFA")
|> background("#7D56F4")
|> padding(2, 4)
|> width(22)
|> render("Hello, kitty")- Installation
- Quick Start
- Colors
- Text Formatting
- Borders
- Layout
- Style Management
- Rendering Options
- Tables
- Lists
- Trees
- Select
- Themes
- Terminal Detection
- License
Add esc to your list of dependencies in mix.exs:
def deps do
[
{:esc, "~> 0.9.1"}
]
endEsc requires Elixir 1.15 or later. See Hex.pm for the latest version.
The simplest way to get started is by importing Esc and chaining style functions:
import Esc
style()
|> bold()
|> foreground("#FAFAFA")
|> background("#7D56F4")
|> padding(2, 4)
|> width(22)
|> render("Hello, kitty")Style is composable, so you can build styles incrementally:
import Esc
base_style = style() |> bold() |> foreground(:cyan)
base_style |> render("Standard")
base_style |> background(:blue) |> render("With background")Esc supports multiple color formats:
import Esc
# Named ANSI colors
style() |> foreground(:red) |> render("Red text")
style() |> foreground(:bright_cyan) |> render("Bright cyan")
# ANSI 256 palette (0-255)
style() |> foreground(196) |> render("Color 196")
# True color (24-bit RGB)
style() |> foreground({255, 128, 0}) |> render("Orange")
# Hex strings
style() |> foreground("#ff8000") |> render("Also orange")
# Background colors work the same way
style() |> background(:blue) |> foreground(:white) |> render("White on blue")Adaptive colors automatically select between variants based on terminal background:
alias Esc.Color
# First arg for light backgrounds, second for dark
color = Color.adaptive("#000000", "#ffffff")Specify exact colors for each terminal capability level:
color = Color.complete(
ansi: :red,
ansi256: 196,
true_color: {255, 0, 0}
)style() |> bold() |> render("Bold")
style() |> italic() |> render("Italic")
style() |> underline() |> render("Underlined")
style() |> strikethrough() |> render("Struck through")
style() |> faint() |> render("Faint/dim")
style() |> blink() |> render("Blinking")
style() |> reverse() |> render("Reversed colors")
# Combine multiple styles
style()
|> bold()
|> italic()
|> foreground(:cyan)
|> render("Bold italic cyan")# Available styles: :normal, :rounded, :thick, :double, :ascii, :markdown, :hidden
style() |> border(:rounded) |> render("Rounded box")
# Border colors
style()
|> border(:double)
|> border_foreground(:cyan)
|> render("Cyan double border")
# Per-side border control
style()
|> border(:normal)
|> border_top(true)
|> border_bottom(true)
|> border_left(false)
|> border_right(false)
|> render("Top and bottom only")
# Custom border characters
style()
|> custom_border(
top: "=", bottom: "=",
left: "|", right: "|",
top_left: "+", top_right: "+",
bottom_left: "+", bottom_right: "+"
)
|> render("Custom border")# All sides
style() |> padding(2) |> render("Padded")
# Vertical, horizontal
style() |> padding(1, 4) |> render("More horizontal padding")
# Top, right, bottom, left
style() |> padding(1, 2, 1, 2) |> render("CSS-style")
# Margins work the same way
style() |> margin(1, 2) |> render("Margined")# Fixed width (content padded/truncated to fit)
style() |> width(30) |> render("Fixed width")
# Fixed height
style() |> height(5) |> render("Fixed height")
# Horizontal alignment: :left, :center, :right
style() |> width(30) |> align(:center) |> render("Centered")
# Vertical alignment: :top, :middle, :bottom
style() |> height(5) |> vertical_align(:middle) |> render("Middle")left = style() |> border(:rounded) |> render("Left")
right = style() |> border(:rounded) |> render("Right")
# Horizontal join with vertical alignment (:top, :middle, :bottom)
Esc.join_horizontal([left, right], :top)
# Vertical join with horizontal alignment (:left, :center, :right)
Esc.join_vertical([left, right], :center)# Place text in a box of specific dimensions
Esc.place(40, 10, :center, :middle, "Centered in 40x10 box")
# Horizontal/vertical placement only
Esc.place_horizontal(40, :right, "Right-aligned in 40 chars")
Esc.place_vertical(10, :bottom, "At bottom of 10 lines")text = "Hello\nWorld"
Esc.get_width(text) # => 5 (widest line)
Esc.get_height(text) # => 2 (line count)base = style() |> foreground(:red) |> bold() |> padding(1)
# Inherit unset properties from base
derived = style() |> foreground(:blue) |> inherit(base)
# Result: blue (overridden), bold (inherited), padding 1 (inherited)style()
|> bold()
|> foreground(:red)
|> unset_foreground() # Remove the red
|> render("Just bold")
# Available: unset_foreground, unset_background, unset_bold, unset_italic,
# unset_underline, unset_padding, unset_margin, unset_border, unset_width, etc.# Strips newlines, ignores width/height constraints
style()
|> inline(true)
|> render("Line 1\nLine 2") # => "Line 1 Line 2"# Truncate content exceeding limits
style() |> max_width(20) |> render("Very long text...")
style() |> max_height(3) |> render("Many\nlines\nof\ntext")# Strip all ANSI codes (preserves layout)
style()
|> foreground(:red)
|> border(:rounded)
|> no_color(true)
|> render("No colors, but has border")upcase_renderer = fn text, _style -> String.upcase(text) end
style()
|> renderer(upcase_renderer)
|> render("hello") # => "HELLO"alias Esc.Table
Table.new()
|> Table.headers(["Name", "Language", "Stars"])
|> Table.row(["Lipgloss", "Go", "8.2k"])
|> Table.row(["Esc", "Elixir", "New!"])
|> Table.row(["Chalk", "JavaScript", "21k"])
|> Table.border(:rounded)
|> Table.header_style(Esc.style() |> Esc.bold() |> Esc.foreground(:cyan))
|> Table.render()Table.new()
|> Table.headers(["Col 1", "Col 2"])
|> Table.rows([["A", "B"], ["C", "D"]]) # Add all rows at once
|> Table.border(:normal) # Border style
|> Table.header_style(style) # Style for headers
|> Table.row_style(style) # Style for all rows
|> Table.style_func(fn row, col -> ... end) # Per-cell styling
|> Table.width(0, 20) # Min width for column 0
|> Table.render()alias Esc.List, as: L
L.new(["First item", "Second item", "Third item"])
|> L.enumerator(:arabic) # 1. 2. 3.
|> L.item_style(Esc.style() |> Esc.foreground(:green))
|> L.render()Output:
1. First item
2. Second item
3. Third item
L.enumerator(:bullet) # • Item
L.enumerator(:dash) # - Item
L.enumerator(:arabic) # 1. 2. 3.
L.enumerator(:roman) # i. ii. iii.
L.enumerator(:alphabet) # a. b. c.
# Custom enumerator function
L.enumerator(fn idx -> "[#{idx + 1}] " end)nested = L.new(["Sub A", "Sub B"]) |> L.enumerator(:dash)
L.new(["Parent 1", nested, "Parent 2"])
|> L.enumerator(:bullet)
|> L.render()alias Esc.Tree
Tree.root("~/Projects")
|> Tree.child(
Tree.root("esc")
|> Tree.child("lib")
|> Tree.child("test")
|> Tree.child("mix.exs")
)
|> Tree.child("other-project")
|> Tree.enumerator(:rounded)
|> Tree.root_style(Esc.style() |> Esc.bold())
|> Tree.render()Tree.new() # Empty tree
Tree.root("Label") # Tree with root
Tree.child(tree, "text") # Add string child
Tree.child(tree, subtree) # Add nested tree
Tree.enumerator(:default) # ├── └──
Tree.enumerator(:rounded) # ├── ╰──
Tree.root_style(style) # Style for root node
Tree.item_style(style) # Style for children
Tree.enumerator_style(style) # Style for connectorsInteractive selection menus for CLI applications. Users navigate with arrow keys (or j/k) and confirm with Enter.
Requires OTP 28+ - The Select component uses OTP 28's native raw terminal mode. See OTP 28 Setup below.
alias Esc.Select
case Select.new(["Option A", "Option B", "Option C"]) |> Select.run() do
{:ok, choice} -> IO.puts("You selected: #{choice}")
:cancelled -> IO.puts("Cancelled")
endItems can be tuples of {display_text, return_value}:
environments = [
{"Production", :prod},
{"Staging", :staging},
{"Development", :dev}
]
case Select.new(environments) |> Select.run() do
{:ok, env} -> deploy_to(env) # env is :prod, :staging, or :dev
:cancelled -> IO.puts("Cancelled")
endSelect.new(["Phoenix", "Plug", "Bandit"])
|> Select.cursor("❯ ") # Custom cursor
|> Select.cursor_style(Esc.style() |> Esc.foreground(:cyan))
|> Select.selected_style(Esc.style() |> Esc.bold())
|> Select.item_style(Esc.style() |> Esc.foreground(:white))
|> Select.run()Select automatically uses theme colors when a global theme is set:
Esc.set_theme(:dracula)
# Cursor uses :emphasis, selected item uses :header
Select.new(["A", "B", "C"]) |> Select.run()
# Disable theme colors
Select.new(["A", "B", "C"]) |> Select.use_theme(false) |> Select.run()| Key | Action |
|---|---|
↑ / k |
Move up |
↓ / j |
Move down |
Enter / Space |
Confirm selection |
q / Escape |
Cancel |
g / Home |
Jump to first |
G / End |
Jump to last |
/ |
Enter filter mode |
] / Ctrl+F |
Next page |
[ / Ctrl+B |
Previous page |
For large lists, items are automatically paginated (default: 100 items per page). Use ]/[ or Ctrl+F/Ctrl+B to navigate between pages:
# Large dataset with default pagination (100 per page)
items = for i <- 1..500, do: {"Item #{i}", i}
Select.new(items) |> Select.run()
# Custom page size
Select.new(items) |> Select.page_size(25) |> Select.run()
# Disable pagination (show all items)
Select.new(items) |> Select.page_size(0) |> Select.run()The page indicator shows [Page 1/5] when multiple pages exist. Navigation with j/k automatically advances to the next/previous page at boundaries.
The Select component requires Erlang/OTP 28 or later for native raw terminal mode support. We recommend using asdf to manage Erlang/Elixir versions:
# Install asdf plugins (if not already installed)
asdf plugin add erlang
asdf plugin add elixir
# Install OTP 28 and compatible Elixir
asdf install erlang 28.3
asdf install elixir 1.19.4-otp-28
# Set versions for your project (creates .tool-versions)
cd your_project
asdf local erlang 28.3
asdf local elixir 1.19.4-otp-28
# Verify
erl -eval 'erlang:display(erlang:system_info(otp_release)), halt().' -noshell
# Should output: "28"Alternatively, add a .tool-versions file to your project:
erlang 28.3
elixir 1.19.4-otp-28
Esc includes 12 built-in themes based on popular terminal color schemes. Set a theme globally to automatically apply colors to all components.
# Set a global theme
Esc.set_theme(:dracula)
# Available themes
Esc.themes()
# => [:dracula, :nord, :gruvbox, :one, :solarized, :monokai,
# :material, :github, :aura, :dolphin, :chalk, :cobalt]The easiest way to use themes is by creating a style with a theme attached. Color atoms like :red, :cyan, :bright_magenta automatically resolve to the theme's RGB values:
# Create a style with Nord theme attached
style(:nord)
|> foreground(:red) # Uses Nord's red (191, 97, 106)
|> background(:cyan) # Uses Nord's cyan (136, 192, 208)
|> render("Themed text")
# Compare to Dracula theme
style(:dracula)
|> foreground(:red) # Uses Dracula's red (255, 85, 85)
|> background(:cyan) # Uses Dracula's cyan (139, 233, 253)
|> render("Different colors!")
# Without a theme, color atoms work as standard ANSI
style()
|> foreground(:red) # Standard ANSI red
|> render("Classic red")All 16 ANSI colors work:
:black,:red,:green,:yellow,:blue,:magenta,:cyan,:white:bright_black,:bright_red,:bright_green,:bright_yellow,:bright_blue,:bright_magenta,:bright_cyan,:bright_white
Semantic colors work everywhere - with or without themes:
:header- Headers, titles:emphasis- Important text:success- Success messages:warning- Warning messages:error- Error messages:muted- Subdued text, borders
# With theme: semantic colors use theme RGB values
style(:nord)
|> foreground(:error) |> render("Error message") # Nord's red (191, 97, 106)
style(:dracula)
|> foreground(:error) |> render("Error message") # Dracula's red (255, 85, 85)
# Without theme: semantic colors fall back to standard ANSI
style()
|> foreground(:error) |> render("Error message") # Standard ANSI red
style()
|> foreground(:success) |> render("Success!") # Standard ANSI green
style()
|> foreground(:warning) |> render("Warning") # Standard ANSI yellowSemantic color ANSI fallbacks (when no theme is set):
:error→:red:success→:green:warning→:yellow:header→:cyan:emphasis→:blue:muted→:bright_black
You can also set a global theme and use theme-specific functions:
Esc.set_theme(:nord)
# Use semantic colors in styles
style() |> Esc.theme_foreground(:error) |> render("Error message")
style() |> Esc.theme_foreground(:success) |> render("Success!")
style() |> Esc.theme_foreground(:warning) |> render("Warning")
style() |> Esc.theme_foreground(:header) |> render("Header text")
style() |> Esc.theme_foreground(:muted) |> render("Subdued text")
# Access theme colors directly
Esc.theme_color(:error) # => {191, 97, 106}
Esc.theme_color(:success) # => {163, 190, 140}When a theme is set, Table, Tree, List, and Select components automatically use theme colors:
Esc.set_theme(:dracula)
# Table headers use :header color, borders use :muted
Table.new()
|> Table.headers(["Name", "Status"])
|> Table.row(["Build", "Passing"])
|> Table.border(:rounded)
|> Table.render()
# Tree root uses :emphasis, connectors use :muted
Tree.root("Project")
|> Tree.child("src")
|> Tree.child("test")
|> Tree.render()
# List enumerators use :muted
List.new(["First", "Second", "Third"])
|> List.enumerator(:arabic)
|> List.render()Disable auto-theming for specific components:
Table.new()
|> Table.use_theme(false) # Disable theme colors
|> Table.headers(["A", "B"])
|> Table.render()Esc.set_theme(:nord) # Set theme by name
Esc.get_theme() # Get current theme struct
Esc.clear_theme() # Clear theme (disable theming)
Esc.themes() # List available theme names
# Configure default theme in config.exs
config :esc, theme: :dracula# Detect color support
Esc.color_profile() # :no_color | :ansi | :ansi256 | :true_color
# Detect background
Esc.has_dark_background?() # true | false
# Force color output (ignores TTY detection)
Application.put_env(:esc, :force_color, true)MIT License - See LICENSE file for details.
Copyright (c) 2025 Vectorfrog






