Elixir bindings for the Rust ratatui terminal UI library, via Rustler NIFs.
Build rich terminal UIs in Elixir with ratatui's layout engine, widget library, and styling system without blocking the BEAM.

Features
- 16 built-in widgets (and counting!): Paragraph, Block, List, Table, Gauge, LineGauge, Tabs, Scrollbar, Checkbox, TextInput, Clear, Markdown, Textarea, Throbber, Popup, WidgetList
- Constraint-based layout engine (percentage, length, min, max, ratio)
- Non-blocking keyboard, mouse, and resize event polling
- OTP-supervised TUI apps via
ExRatatui.Appbehaviour with LiveView-inspired callbacks - Reducer runtime for command/subscription driven apps via
use ExRatatui.App, runtime: :reducer - Built-in SSH transport: serve any
ExRatatui.Appas a remote TUI, standalone or undernerves_ssh - Erlang distribution transport: attach to a remote TUI over Erlang distribution with zero NIF on the app node
- Full color support: named, RGB, and 256-color indexed
- Text modifiers: bold, italic, underlined, and more
- Headless test backend for CI-friendly rendering verification
- Precompiled NIF binaries: no Rust toolchain needed
- Runs on BEAM's DirtyIo scheduler: never blocks your processes
Examples
| Example | Run | Description |
|---|---|---|
hello_world.exs | mix run examples/hello_world.exs | Minimal paragraph display |
counter.exs | mix run examples/counter.exs | Interactive counter with key events |
counter_app.exs | mix run examples/counter_app.exs | Counter using ExRatatui.App behaviour |
reducer_counter_app.exs | mix run examples/reducer_counter_app.exs | Counter using the reducer runtime with subscriptions |
system_monitor.exs | mix run examples/system_monitor.exs | Linux system dashboard: CPU, memory, disk, network, BEAM stats (Linux/Nerves only). Also runs over SSH and Erlang distribution (see below). |
widget_showcase.exs | mix run examples/widget_showcase.exs | Interactive showcase: tabs, progress bars, checkboxes, text input, scrollable logs |
task_manager.exs | mix run examples/task_manager.exs | Full task manager with tabs, table, scrollbar, line gauge, and more |
chat_interface.exs | mix run examples/chat_interface.exs | AI chat interface: markdown, textarea, throbber, popup, slash commands |
task_manager/ | See README | Supervised Ecto + SQLite CRUD app. Also runs over SSH, multiple clients share one DB |
Try an example over SSH
mix run --no-halt examples/system_monitor.exs --ssh
# in another terminal:
ssh demo@localhost -p 2222 # password: demo
Try an example over Erlang Distribution
# Terminal 1 — start the app node
elixir --sname app --cookie demo -S mix run --no-halt examples/system_monitor.exs --distributed
# Terminal 2 — attach from another node
iex --sname local --cookie demo -S mix
iex> ExRatatui.Distributed.attach(:"app@hostname", SystemMonitor)
Built with ExRatatui
- ash_tui — Interactive terminal explorer for Ash domains, resources, attributes, actions, and more.
- switchyard — Full-featured reducer runtime workbench exercising command batching, async effects, subscription reconciliation, runtime snapshots, distributed attach, and row-scrolled WidgetList.
- nerves_ex_ratatui_example — Example Nerves project with three TUIs (system monitor, LED control, and a reducer-runtime system monitor) on embedded hardware, reachable over SSH subsystems and Erlang distribution.
- phoenix_ex_ratatui_example — Example Phoenix project with two TUIs (callback and reducer runtime) served over SSH and Erlang distribution alongside a public LiveView chat room, sharing PubSub between the browser and the terminal.
Installation
Add ex_ratatui to your dependencies in mix.exs:
def deps do
[
{:ex_ratatui, "~> 0.7"}
]
endThen fetch and compile:
mix deps.get && mix compile
A precompiled NIF binary for your platform will be downloaded automatically.
The native library itself is loaded lazily on first use, so compiling a
project that depends on ex_ratatui does not require the NIF to be loaded
into the compiler VM.
Prerequisites
- Elixir 1.17+
Precompiled NIF binaries are available for Linux (x86_64, aarch64, armv6/hf, riscv64), macOS (x86_64, aarch64), and Windows (x86_64). No Rust toolchain needed.
To compile from source instead, install the Rust toolchain and set:
export EX_RATATUI_BUILD=true
Quick Start
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Paragraph}
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 a keypress, then exit
ExRatatui.poll_event(60_000)
end)Try the examples for more, e.g. mix run examples/hello_world.exs.
Learning Path
New to ExRatatui? Follow this progression:
- Run an example —
mix run examples/hello_world.exsto see it work - Read Building UIs — widgets, layout, styles, and events
- Read Callback Runtime — build a supervised OTP app with
ExRatatui.App - Try the counter —
mix run examples/counter_app.exsto see callbacks in action - (Optional) Read Reducer Runtime for async commands and subscriptions
- Deploy remotely — read the SSH or Distribution guide
Choosing a Runtime
ExRatatui offers two runtime modes for supervised apps. Both are transport-agnostic — the same module works over local terminal, SSH, or Erlang distribution without changes.
| Callback Runtime | Reducer Runtime | |
|---|---|---|
| Opt-in | use ExRatatui.App (default) | use ExRatatui.App, runtime: :reducer |
| Entry point | mount/1 | init/1 |
| Events | handle_event/2 + handle_info/2 | Single update/2 receives {:event, _} and {:info, _} |
| Side effects | Direct (send, spawn, etc.) | First-class Command primitives (message, send_after, async, batch) |
| Timers | Manual Process.send_after/3 | Declarative Subscription with auto-reconciliation |
| Tracing | Not built-in | Built-in via ExRatatui.Runtime |
| Best for | Straightforward interactive TUIs | Apps with async I/O, structured effects, or complex state machines |
Choosing a Transport
All transports serve the same ExRatatui.App module — switch by changing a single option.
| Local (default) | SSH | Erlang Distribution | |
|---|---|---|---|
| Opt-in | Automatic | transport: :ssh | ExRatatui.Distributed.attach/3 |
| NIF required on | App node | App node (daemon) | Client node only |
| Multi-client | No (one terminal) | Yes (isolated per connection) | Yes (isolated per connection) |
| Auth | N/A | Password, public key, or custom | Erlang cookie |
| Best for | Local dev, Nerves console | Remote admin TUIs, Phoenix SSH | Headless nodes, cross-architecture |
| Session isolation | N/A | Full (each client gets own state) | Full (each client gets own state) |
| Network | N/A | TCP (SSH protocol) | Erlang distribution protocol |
Guides
| Guide | Description |
|---|---|
| Callback Runtime | OTP-supervised apps with mount, render, handle_event, and handle_info callbacks |
| Reducer Runtime | Elm-style apps with init, update, subscriptions, commands, and runtime inspection |
| Building UIs | Widgets, layout, styles, and events — everything for render/2 |
| Running TUIs over SSH | Serve any app as a remote TUI over SSH, standalone or under nerves_ssh |
| Running TUIs over Erlang Distribution | Drive a TUI from a remote BEAM node with zero NIF on the app side |
How It Works
ExRatatui bridges Elixir and Rust through Rustler NIFs (Native Implemented Functions):
Elixir structs -> encode to maps -> Rust NIF -> decode to ratatui types -> render to terminal
Terminal events -> Rust NIF (DirtyIo) -> encode to tuples -> Elixir Event structs- Rendering: Elixir widget structs are encoded as string-keyed maps, passed across the NIF boundary, and decoded into ratatui widget types for rendering.
- Events: The
poll_eventNIF runs on BEAM's DirtyIo scheduler, so event polling never blocks normal Elixir processes. - Terminal state: Each process holds its own terminal reference via Rust ResourceArc, supporting two backends — a real crossterm terminal and a headless test backend for CI. The terminal is automatically restored when the reference is garbage collected.
- Layout: Ratatui's constraint-based layout engine is exposed directly, computing split rectangles on the Rust side and returning them as Elixir tuples.
Precompiled binaries are provided via rustler_precompiled so users don't need the Rust toolchain.
Process Architecture
Each transport builds on the same internal Server, which owns the render loop and dispatches to your ExRatatui.App callbacks:
Local transport:
Supervisor
└── Server (GenServer)
├── owns terminal reference (NIF)
├── polls events on DirtyIo scheduler
└── calls your mount/render/handle_event
SSH transport:
Supervisor
└── SSH.Daemon (GenServer, wraps :ssh.daemon)
└── per client:
SSH channel (:ssh_server_channel)
├── owns Session (in-memory terminal)
├── parses ANSI input → events
└── Server (GenServer)
└── calls your mount/render/handle_event
Distributed transport:
App node Client node
├── Distributed.Listener └── Distributed.Client (GenServer)
│ └── DynamicSupervisor ├── owns terminal reference (NIF)
│ └── per client: ├── polls events locally
│ Server (GenServer) └── sends events → Server
│ └── sends widgets → Client receives widgets ← Server
└── No NIF needed hereAll transports provide full session isolation — each connected client gets its own Server process with independent state.
Testing
ExRatatui includes a headless test backend for CI-friendly rendering verification. Each test terminal is independent, and test_mode disables live terminal input polling so async: true tests do not race ambient TTY events:
test "renders a paragraph" do
terminal = ExRatatui.init_test_terminal(40, 10)
paragraph = %Paragraph{text: "Hello!"}
:ok = ExRatatui.draw(terminal, [{paragraph, %Rect{x: 0, y: 0, width: 40, height: 10}}])
content = ExRatatui.get_buffer_content(terminal)
assert content =~ "Hello!"
endFor supervised apps started under test_mode, use
ExRatatui.Runtime.inject_event/2 to drive input deterministically:
{:ok, pid} = MyApp.TUI.start_link(name: nil, test_mode: {40, 10})
event = %ExRatatui.Event.Key{code: "q", modifiers: [], kind: "press"}
:ok = ExRatatui.Runtime.inject_event(pid, event)Troubleshooting
Terminal looks garbled or colors are wrong
Make sure your terminal emulator supports 256-color or true color. Most modern terminals (iTerm2, Ghostty, Alacritty, Windows Terminal, Kitty) work out of the box. If using tmux or screen, set TERM=xterm-256color.
SSH client hangs or shows no output
Connect with PTY allocation forced: ssh -t user@host -p 2222. Without -t, most SSH clients don't allocate a pseudo-terminal, and the TUI has nowhere to render. See the SSH guide for details.
mix run examples/... exits immediately
Make sure you're not piping or redirecting stdin. The TUI needs an interactive terminal to poll events. If running in a non-interactive context, use --no-halt for daemon-mode examples (SSH, distributed).
Tests fail with "terminal_init_failed"
This happens when a test tries to start a real terminal without a TTY (common in CI or when backgrounding). Use test_mode: {width, height} to start a headless test backend instead.
Debugging rendering issues Use the headless test backend to inspect buffer contents:
terminal = ExRatatui.init_test_terminal(80, 24)
ExRatatui.draw(terminal, [{widget, rect}])
IO.puts(ExRatatui.get_buffer_content(terminal))For supervised apps, use ExRatatui.Runtime.snapshot/1 to inspect runtime state and ExRatatui.Runtime.enable_trace/2 to capture state transitions.
Contributing
Contributions are welcome! See CONTRIBUTING.md for development setup and PR guidelines.
License
MIT — see LICENSE for details.