Esc
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")Table of Contents
- Installation
- Quick Start
- Colors
- Text Formatting
- Borders
- Layout
- Style Management
- Rendering Options
- Tables
- Lists
- Trees
- Select
- Themes
- Terminal Detection
- License
Installation
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.
Quick Start
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")Colors
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
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")Complete Colors
Specify exact colors for each terminal capability level:
color = Color.complete(
ansi: :red,
ansi256: 196,
true_color: {255, 0, 0}
)Text Formatting
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")Borders
# 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")Layout
Padding and Margins
# 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")Dimensions and Alignment
# 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")Joining Blocks
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)Placement
# 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")Measurement
text = "Hello\nWorld"
Esc.get_width(text) # => 5 (widest line)
Esc.get_height(text) # => 2 (line count)Style Management
Inheritance
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)Unsetting Properties
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.Rendering Options
Inline Mode
# Strips newlines, ignores width/height constraints
style()
|> inline(true)
|> render("Line 1\nLine 2") # => "Line 1 Line 2"Max Dimensions
# Truncate content exceeding limits
style() |> max_width(20) |> render("Very long text...")
style() |> max_height(3) |> render("Many\nlines\nof\ntext")No Color Mode
# Strip all ANSI codes (preserves layout)
style()
|> foreground(:red)
|> border(:rounded)
|> no_color(true)
|> render("No colors, but has border")Custom Renderers
upcase_renderer = fn text, _style -> String.upcase(text) end
style()
|> renderer(upcase_renderer)
|> render("hello") # => "HELLO"Tables
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 Options
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()Lists
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 itemEnumerator Styles
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 Lists
nested = L.new(["Sub A", "Sub B"]) |> L.enumerator(:dash)
L.new(["Parent 1", nested, "Parent 2"])
|> L.enumerator(:bullet)
|> L.render()Trees
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 Options
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 connectorsSelect
Interactive 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 with Return Values
Items 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")
endStyling
Select.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()Theme Integration
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()Keyboard Controls
| 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 |
Pagination
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.
OTP 28 Setup
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-28Themes
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]Theme-Aware Styles
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
Global Theme Colors
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}Auto-Themed Components
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()Theme Management
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: :draculaTerminal Detection
# 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)License
MIT License - See LICENSE file for details.
Copyright (c) 2025 Vectorfrog