This guide walks you from an empty mix new project to a supervised terminal UI you can actually use. Read it top-to-bottom the first time; come back later for the capstone module.
Every concept is introduced with runnable code. If you get stuck, compare your file against the snippets or run the matching example from examples/.
What you'll build
A small todo app. Type an item, press Enter to add it, move selection with ↑/↓ (or k/j), press d to delete, Tab to switch focus between the input and the list, Esc to quit.
┌────────────────────────────────────────────────────────┐
│ Todo │
├────────────────────────────────────────────────────────┤
│ › buy groceries_ │
├────────────────────────────────────────────────────────┤
│ walk the dog │
│ ›file taxes │
│ call mom │
│ │
└────────────────────────────────────────────────────────┘
Tab switch · Enter add · d delete · Esc quit~80 lines of Elixir. No JavaScript, no web server, no Rust toolchain on your machine.
Install and verify
Create a project and add the dependency:
mix new my_tui --sup
cd my_tui
Edit mix.exs:
defp deps do
[
{:ex_ratatui, "~> 0.8"}
]
endFetch:
mix deps.get
mix compile
A precompiled NIF for your platform downloads automatically — you don't need Rust installed. On first compile you'll see rustler_precompiled fetch the binary.
Verify it works. Create lib/hello.ex:
defmodule Hello do
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
def run do
ExRatatui.run(fn terminal ->
{w, h} = ExRatatui.terminal_size()
paragraph = %Paragraph{
text: "Hello from ExRatatui!\n\nPress any key to exit.",
style: %Style{fg: :green, modifiers: [:bold]},
alignment: :center,
block: %Block{
title: " Hello World ",
borders: [:all],
border_type: :rounded,
border_style: %Style{fg: :cyan}
}
}
ExRatatui.draw(terminal, [{paragraph, %Rect{x: 0, y: 0, width: w, height: h}}])
wait_for_key()
end)
end
defp wait_for_key do
case ExRatatui.poll_event(5_000) do
nil -> wait_for_key()
_ -> :ok
end
end
endRun it:
iex -S mix
iex> Hello.run()
You should see a rounded box with a green centered message. Press any key to return to IEx.
If instead you get terminal_init_failed, you're likely in a non-TTY shell (IDE terminal, background process, piped stdin). Run from a real terminal emulator — see the Debugging guide for more.
Your first render
Let's look at what that snippet actually did.
ExRatatui.run(fn terminal ->
# ...
end)ExRatatui.run/1 takes a function, puts the terminal into raw mode, gives you a terminal reference, and guarantees the terminal is restored when the function returns (or raises). This is the right shape for a one-shot script but not for a supervised app — we'll replace it in a minute.
{w, h} = ExRatatui.terminal_size()Returns the current size in cells. The terminal won't automatically resize your widgets — you place them explicitly, in cell coordinates.
paragraph = %Paragraph{
text: "Hello from ExRatatui!\n\nPress any key to exit.",
style: %Style{fg: :green, modifiers: [:bold]},
alignment: :center,
block: %Block{title: " Hello World ", borders: [:all], border_type: :rounded}
}A widget is a struct. It doesn't draw anything on its own — it's a value you hand to draw/2. Paragraph renders text; Block is a decorative wrapper most widgets accept via a :block field for a title and borders.
ExRatatui.draw(terminal, [{paragraph, %Rect{x: 0, y: 0, width: w, height: h}}])draw/2 takes a list of {widget, rect} tuples. Each %Rect{} says where to paint the widget. You can pass many tuples — the whole frame is rendered in one go.
ExRatatui.poll_event(5_000)Polls for a terminal event (key press, mouse, resize) with a timeout in milliseconds. Returns nil on timeout, an %Event.Key{} / %Event.Mouse{} / %Event.Resize{} otherwise. Event polling runs on the BEAM's DirtyIo scheduler so your other processes keep running.
That's the whole "bare metal" API: size, build structs, draw, poll. For a supervised long-running app, you want the next layer.
Switch to ExRatatui.App
ExRatatui.run/1 is fine for scripts. For a real app you want supervision, test support, and transports (SSH, distribution). That's what the ExRatatui.App behaviour gives you.
Replace lib/hello.ex with:
defmodule Hello do
use ExRatatui.App
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
@impl true
def mount(_opts) do
{:ok, %{}}
end
@impl true
def render(_state, frame) do
paragraph = %Paragraph{
text: "Hello from ExRatatui!\n\nPress any key to exit.",
style: %Style{fg: :green, modifiers: [:bold]},
alignment: :center,
block: %Block{
title: " Hello World ",
borders: [:all],
border_type: :rounded,
border_style: %Style{fg: :cyan}
}
}
[{paragraph, %Rect{x: 0, y: 0, width: frame.width, height: frame.height}}]
end
@impl true
def handle_event(%ExRatatui.Event.Key{kind: "press"}, state) do
{:stop, state}
end
def handle_event(_event, state), do: {:noreply, state}
endThree callbacks:
mount/1— runs once when the app starts. Returns{:ok, state}. Here the state is empty.render/2— runs after every state change. Getsstateand a%Frame{width:, height:}with the current terminal size. Returns[{widget, rect}, ...]. Always return the full scene — the runtime diffs cells between frames for you, but your job is to describe the whole screen.handle_event/2— receives a terminal event. Returns{:noreply, new_state}to keep running or{:stop, state}to quit. Here any key press quits.
Run it:
iex -S mix
iex> {:ok, _pid} = Hello.start_link(name: nil)
iex> Process.monitor(_pid) # optional — block until the app exits
Under a supervisor, you'd add it like any other child:
# lib/my_tui/application.ex
children = [
Hello
]Same behavior as before, but now you have a proper OTP process you can test, supervise, and serve remotely. The SSH and Distribution guides serve this exact module over the network with no code changes.
State and events
A static paragraph isn't very interactive. Let's add a counter.
defmodule Hello do
use ExRatatui.App
alias ExRatatui.{Event, Layout}
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
@impl true
def mount(_opts), do: {:ok, %{count: 0}}
@impl true
def render(state, frame) do
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
widget = %Paragraph{
text: "\n\n Count: #{state.count}",
style: %Style{fg: :white, modifiers: [:bold]},
alignment: :center,
block: %Block{title: " Counter ", borders: [:all], border_type: :rounded}
}
[{widget, area}]
end
@impl true
def handle_event(%Event.Key{code: "q", kind: "press"}, state), do: {:stop, state}
def handle_event(%Event.Key{code: code, kind: "press"}, state) when code in ["up", "k"] do
{:noreply, %{state | count: state.count + 1}}
end
def handle_event(%Event.Key{code: code, kind: "press"}, state) when code in ["down", "j"] do
{:noreply, %{state | count: state.count - 1}}
end
def handle_event(_, state), do: {:noreply, state}
endTwo things to notice.
Events come in handle_event clauses. An %Event.Key{} carries :code (a string like "up", "a", "enter", "esc"), :modifiers (e.g. [:ctrl]), and :kind ("press" / "repeat" / "release"). Pattern-match on the shape you care about; fall through to a catch-all {:noreply, state} so unhandled events don't crash.
Return values control the loop:
{:noreply, state}— update state and re-render{:noreply, state, opts}— same, with options likerender?: falseto skip the re-render (see the Performance guide){:stop, state}— exit cleanly
Run it, press ↑/↓ or k/j, watch the counter. Press q to quit.
Layout
Placing everything in one giant Rect gets old fast. Layout.split/3 divides a rectangle into regions using constraints.
def render(state, frame) do
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
[header, body, footer] = Layout.split(area, :vertical, [
{:length, 3},
{:min, 0},
{:length, 1}
])
header_widget = %Paragraph{
text: " Counter",
style: %Style{fg: :cyan, modifiers: [:bold]},
block: %Block{borders: [:all], border_type: :rounded}
}
body_widget = %Paragraph{
text: "\n\n Count: #{state.count}",
alignment: :center,
block: %Block{borders: [:all], border_type: :rounded}
}
footer_widget = %Paragraph{
text: " ↑/k +1 · ↓/j -1 · q quit",
style: %Style{fg: :dark_gray}
}
[{header_widget, header}, {body_widget, body}, {footer_widget, footer}]
endConstraint types you'll use most:
{:length, n}— exactlyncells{:min, n}— at leastn, expand to fill remaining space{:percentage, n}—n% of the parent{:ratio, num, den}—num/denof the parent
Layout.split/3 returns a list of %Rect{} in the same order as your constraints. Chain splits to build grids — split vertically into rows, then split each row horizontally into columns. The Building UIs guide goes deep on constraints.
Styling and rich text
%Style{} carries foreground, background, and modifiers. Accepts named colors (:green), RGB ({:rgb, 255, 100, 0}), and 256-color indices ({:indexed, 42}).
%Style{fg: :red, bg: {:rgb, 30, 30, 30}, modifiers: [:bold, :underlined]}Most widgets take a top-level :style plus part-specific fields like :highlight_style or :border_style.
Rich text lets one string carry per-segment styling. Build a %Span{} for a styled run and group them with %Line{}:
alias ExRatatui.Text.{Line, Span}
%Paragraph{
text: Line.new([
Span.new(" ok ", style: %Style{fg: :black, bg: :green}),
Span.new(" Count: #{state.count}", style: %Style{modifiers: [:bold]})
])
}Paragraph.text, List.items, Table cells, Tabs.titles, and Block.title all accept rich text. Plain strings keep working everywhere — you only reach for spans when you want mixed styling on one line.
Make negative counts red:
style = if state.count < 0, do: %Style{fg: :red}, else: %Style{fg: :white}
body_widget = %Paragraph{
text: "\n\n Count: #{state.count}",
style: style,
alignment: :center,
block: %Block{borders: [:all], border_type: :rounded}
}Capstone: a small todo app
Time to put it together. This app introduces two things you haven't seen:
- A stateful widget —
TextInputowns a NIF-side editor state. You create the state once inmount/1, keep the reference in your state map, and pass it to the widget on every render. Focus management — two regions (input, list) want different keybindings. We track a
focus: :input | :listatom and dispatch keys accordingly. For multi-panel apps with more than a couple of regions,ExRatatui.Focusgives you a proper focus ring; see Building UIs for that pattern.
Create lib/todo.ex:
defmodule Todo do
use ExRatatui.App
alias ExRatatui.{Event, Layout, Style}
alias ExRatatui.Layout.Rect
alias ExRatatui.Widgets.{Block, List, Paragraph, TextInput}
@impl true
def mount(_opts) do
{:ok,
%{
input: ExRatatui.text_input_new(),
items: [],
selected: 0,
focus: :input
}}
end
@impl true
def render(state, frame) do
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
[header, input_rect, list_rect, footer] =
Layout.split(area, :vertical, [
{:length, 3},
{:length, 3},
{:min, 0},
{:length, 1}
])
[
{header_widget(), header},
{input_widget(state), input_rect},
{list_widget(state), list_rect},
{footer_widget(), footer}
]
end
# ---- events ----------------------------------------------------------
@impl true
def handle_event(%Event.Key{code: "esc", kind: "press"}, state) do
{:stop, state}
end
def handle_event(%Event.Key{code: "tab", kind: "press"}, state) do
{:noreply, toggle_focus(state)}
end
def handle_event(%Event.Key{kind: "press"} = key, %{focus: :input} = state) do
{:noreply, handle_input_key(state, key)}
end
def handle_event(%Event.Key{kind: "press"} = key, %{focus: :list} = state) do
{:noreply, handle_list_key(state, key)}
end
def handle_event(_event, state), do: {:noreply, state}
# ---- input focus -----------------------------------------------------
defp handle_input_key(state, %Event.Key{code: "enter"}) do
case ExRatatui.text_input_get_value(state.input) do
"" ->
state
text ->
:ok = ExRatatui.text_input_set_value(state.input, "")
%{state | items: state.items ++ [text]}
end
end
defp handle_input_key(state, %Event.Key{code: code}) do
:ok = ExRatatui.text_input_handle_key(state.input, code)
state
end
# ---- list focus ------------------------------------------------------
defp handle_list_key(state, %Event.Key{code: code}) when code in ["up", "k"] do
%{state | selected: max(state.selected - 1, 0)}
end
defp handle_list_key(state, %Event.Key{code: code}) when code in ["down", "j"] do
max_index = max(length(state.items) - 1, 0)
%{state | selected: min(state.selected + 1, max_index)}
end
defp handle_list_key(state, %Event.Key{code: "d"}) do
items = delete_at(state.items, state.selected)
%{state | items: items, selected: min(state.selected, max(length(items) - 1, 0))}
end
defp handle_list_key(state, _), do: state
# ---- widgets ---------------------------------------------------------
defp header_widget do
%Paragraph{
text: " Todo",
style: %Style{fg: :cyan, modifiers: [:bold]},
block: %Block{borders: [:all], border_type: :rounded, border_style: %Style{fg: :dark_gray}}
}
end
defp input_widget(state) do
%TextInput{
state: state.input,
placeholder: "Add a todo…",
block: %Block{
borders: [:all],
border_type: :rounded,
border_style: border_style(state.focus, :input)
}
}
end
defp list_widget(state) do
%List{
items: state.items,
selected: if(state.items == [], do: nil, else: state.selected),
highlight_symbol: "› ",
highlight_style: %Style{fg: :black, bg: :yellow, modifiers: [:bold]},
block: %Block{
borders: [:all],
border_type: :rounded,
border_style: border_style(state.focus, :list)
}
}
end
defp footer_widget do
%Paragraph{
text: " Tab switch · Enter add · d delete · Esc quit",
style: %Style{fg: :dark_gray}
}
end
# ---- helpers ---------------------------------------------------------
defp toggle_focus(%{focus: :input} = state), do: %{state | focus: :list}
defp toggle_focus(%{focus: :list} = state), do: %{state | focus: :input}
defp border_style(focus, id) do
if focus == id,
do: %Style{fg: :yellow, modifiers: [:bold]},
else: %Style{fg: :dark_gray}
end
defp delete_at(list, n), do: Enum.take(list, n) ++ Enum.drop(list, n + 1)
endRun it:
iex -S mix
iex> {:ok, pid} = Todo.start_link(name: nil)
iex> ref = Process.monitor(pid)
iex> receive do {:DOWN, ^ref, _, _, _} -> :ok end
Things worth highlighting:
TextInputstate is created inmount/1, never inrender/2. Creating it on every render would lose the cursor and typed text between frames. The reference lives in state; the widget just reads it.handle_event/2dispatches by focus. The patterndef handle_event(%Event.Key{…} = key, %{focus: :input} = state)is a clean way to split behavior without nestedcase.- Guard against empty state.
List.selectedis set tonilwhen there are no items so you don't highlight row zero of an empty list. Delete clamps the new selection to the new length. - Focus shows visually. The focused region gets a bold yellow border. This is a minimal pattern — for more panels, reach for
ExRatatui.Focus.
Where to go next
You now have a working local supervised TUI with input, a list, and focus. From here, pick the guide that matches what you want to learn:
- Building UIs — full widget reference, layout deep-dive, rich text, events,
ExRatatui.Focus. - Callback Runtime — all callbacks (
mount,render,handle_event,handle_info,terminate), options, lifecycle details. - Reducer Runtime — Elm-style alternative with first-class commands, subscriptions, and runtime inspection. Good when you have async work or want structured side effects.
- Custom Widgets — compose primitives into reusable widgets via the
ExRatatui.Widgetprotocol. - State Machine Patterns — multi-screen apps, modals, conditional UI.
- Testing — headless test backend,
inject_event,Runtime.snapshot, property-based tests. - Debugging —
Runtime.snapshot, tracing, buffer inspection, common errors. - Performance —
render?: false, poll tuning, keepingrender/2cheap. - Running TUIs over SSH — serve this exact app to remote clients.
- Running TUIs over Erlang Distribution — drive the TUI from a different BEAM node.
Or browse the examples/ folder for more patterns — focus_multi_panel.exs, chat_interface.exs, and task_manager/ are good next reads.