Ratatui: Terminal UI Framework for Rust
Ratatui: Terminal UI Framework for Rust
Terminal user interfaces occupy a unique space in software development. They run everywhere -- SSH sessions, containers, old servers, embedded systems. They start instantly. They consume almost no resources. And when done well, they can be genuinely beautiful. Tools like htop, lazygit, and bottom prove that terminal UIs can be both functional and elegant.
Ratatui is the Rust framework for building these kinds of applications. It is a community fork of the original tui-rs library, now the de facto standard for terminal UI development in Rust. Ratatui gives you a widget-based rendering system, a flexible layout engine, and an immediate-mode architecture that fits naturally with Rust's ownership model. You describe what the screen should look like on each frame, and Ratatui handles the diffing and rendering efficiently.
Why Ratatui?
Immediate Mode Rendering
Ratatui uses an immediate-mode rendering model. On every frame, you describe the entire UI from scratch based on your current application state. Ratatui diffs the new frame against the previous one and only writes the characters that actually changed to the terminal. This model has several advantages:
- No UI state to manage: There are no callbacks, no observers, no mutable widget trees. Your application state is the single source of truth.
- Easy to reason about: The rendering function is a pure function from state to UI. If the state is correct, the UI is correct.
- Fits Rust's ownership model: No need for
Rc<RefCell<>>wrappers or interior mutability patterns to share state between widgets.
Performance
Ratatui is fast enough that you can redraw the entire screen on every keystroke without noticeable latency. The diff algorithm ensures that only changed cells are written to the terminal, minimizing I/O. On a typical application, a full render cycle takes less than 1 millisecond.
Ecosystem
Ratatui has a growing ecosystem of companion crates:
crosstermortermionfor terminal backend abstractionratatui-imagefor displaying images in the terminaltui-textareafor multi-line text editingtui-inputfor single-line input fieldstui-scrollviewfor scrollable content regionstui-big-textfor large ASCII text rendering
Getting Started
Project Setup
Create a new Rust project and add the dependencies:
cargo new my-tui-app
cd my-tui-app
cargo add ratatui crossterm
Ratatui needs a terminal backend. The two main options are crossterm (cross-platform, works on Windows) and termion (Unix-only, slightly simpler API). This guide uses crossterm because it works everywhere.
Minimal Application
Here is the smallest possible Ratatui application:
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
terminal::{disable_raw_mode, enable_raw_mode,
EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::{prelude::*, widgets::Paragraph};
use std::io::{self, stdout};
fn main() -> io::Result<()> {
// Setup: enter raw mode and alternate screen
stdout().execute(EnterAlternateScreen)?;
enable_raw_mode()?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
// Main loop
loop {
// Draw
terminal.draw(|frame| {
let area = frame.area();
frame.render_widget(
Paragraph::new("Hello, Ratatui! Press 'q' to quit.")
.alignment(Alignment::Center),
area,
);
})?;
// Handle input
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
break;
}
}
}
// Teardown: restore terminal state
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
This application enters the alternate screen buffer (so your existing terminal content is preserved), renders a centered paragraph, and exits when you press q. The setup and teardown pattern is important -- if your application panics without restoring terminal state, the user's terminal will be in raw mode. Production applications should use a panic hook to handle this.
Panic Handler
Always install a panic hook that restores the terminal:
fn init_panic_hook() {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
// Restore terminal before printing panic
let _ = disable_raw_mode();
let _ = stdout().execute(LeaveAlternateScreen);
original_hook(panic_info);
}));
}
Call init_panic_hook() at the start of main(), before entering raw mode.
Layout System
Ratatui's layout engine divides the terminal area into rectangular regions using constraints. This is similar to CSS flexbox but simpler.
Basic Layouts
use ratatui::layout::{Layout, Constraint, Direction};
// Vertical split: 3 rows
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Fixed 3 rows (header)
Constraint::Min(0), // Fill remaining space (body)
Constraint::Length(1), // Fixed 1 row (footer)
])
.split(frame.area());
// chunks[0] = header area
// chunks[1] = body area
// chunks[2] = footer area
Nested Layouts
Combine horizontal and vertical layouts for complex UIs:
// Top-level: header, body, footer
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(frame.area());
// Body: sidebar + content
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20), // Sidebar: 20% width
Constraint::Percentage(80), // Content: 80% width
])
.split(main_chunks[1]);
// body_chunks[0] = sidebar
// body_chunks[1] = main content
Constraint Types
| Constraint | Behavior |
|---|---|
Length(n) |
Exactly n cells |
Min(n) |
At least n cells, grows to fill |
Max(n) |
At most n cells |
Percentage(n) |
n% of the parent area |
Ratio(a, b) |
a/b of the parent area |
Fill(weight) |
Fill remaining space proportionally |
Core Widgets
Ratatui ships with a comprehensive set of built-in widgets.
Paragraph
The most versatile widget for displaying text:
use ratatui::widgets::{Paragraph, Block, Borders, Wrap};
use ratatui::text::{Line, Span};
use ratatui::style::{Style, Color, Modifier};
let text = vec![
Line::from(vec![
Span::styled("Error: ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Connection refused on port 5432"),
]),
Line::from(""),
Line::from(Span::styled(
"Check that PostgreSQL is running.",
Style::default().fg(Color::Yellow),
)),
];
let paragraph = Paragraph::new(text)
.block(Block::default().title("Database Status").borders(Borders::ALL))
.wrap(Wrap { trim: true })
.scroll((scroll_offset, 0));
frame.render_widget(paragraph, area);
Table
For structured data with selectable rows:
use ratatui::widgets::{Table, Row, Cell};
let header = Row::new(vec![
Cell::from("PID").style(Style::default().fg(Color::Yellow)),
Cell::from("Name").style(Style::default().fg(Color::Yellow)),
Cell::from("CPU %").style(Style::default().fg(Color::Yellow)),
Cell::from("Memory").style(Style::default().fg(Color::Yellow)),
]);
let rows = processes.iter().map(|p| {
Row::new(vec![
Cell::from(p.pid.to_string()),
Cell::from(p.name.clone()),
Cell::from(format!("{:.1}%", p.cpu)),
Cell::from(format!("{} MB", p.memory / 1024)),
])
});
let table = Table::new(rows, [
Constraint::Length(8), // PID column
Constraint::Min(20), // Name column
Constraint::Length(8), // CPU column
Constraint::Length(12), // Memory column
])
.header(header)
.block(Block::default().title("Processes").borders(Borders::ALL))
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol(">> ");
// Use StatefulWidget for selection
frame.render_stateful_widget(table, area, &mut table_state);
Charts
Ratatui includes built-in chart widgets for sparklines, bar charts, and line charts:
use ratatui::widgets::{Chart, Dataset, Axis, GraphType};
use ratatui::symbols::Marker;
let data: Vec<(f64, f64)> = cpu_history
.iter()
.enumerate()
.map(|(i, &v)| (i as f64, v))
.collect();
let dataset = Dataset::default()
.name("CPU Usage")
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::Cyan))
.data(&data);
let chart = Chart::new(vec![dataset])
.block(Block::default().title("CPU History").borders(Borders::ALL))
.x_axis(Axis::default()
.bounds([0.0, 60.0])
.labels(vec!["60s ago".into(), "30s ago".into(), "now".into()]))
.y_axis(Axis::default()
.bounds([0.0, 100.0])
.labels(vec!["0%".into(), "50%".into(), "100%".into()]));
frame.render_widget(chart, area);
Other Built-in Widgets
- List: Scrollable, selectable list of items
- Tabs: Tab bar for switching between views
- Gauge: Progress bars (standard and line gauge)
- Sparkline: Compact inline charts
- BarChart: Vertical or horizontal bar charts
- Canvas: Low-level drawing primitive for custom shapes
- Calendar: Monthly calendar view
- Scrollbar: Visual scroll indicator
Event Handling
Ratatui itself does not handle events -- that is the job of the terminal backend (crossterm). A typical event loop looks like this:
use crossterm::event::{self, Event, KeyCode, KeyModifiers, KeyEventKind};
use std::time::Duration;
fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
loop {
// Draw current state
terminal.draw(|frame| ui(frame, app))?;
// Poll for events with timeout (enables animations/updates)
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(());
}
KeyCode::Up | KeyCode::Char('k') => app.previous(),
KeyCode::Down | KeyCode::Char('j') => app.next(),
KeyCode::Enter => app.select(),
KeyCode::Tab => app.next_tab(),
KeyCode::Esc => app.back(),
_ => {}
}
}
Event::Resize(_, _) => {} // Terminal handles redraw
_ => {}
}
}
// Update application state (timers, data fetching, etc.)
app.tick();
}
}
Async Event Handling
For applications that need to handle I/O alongside user input, use tokio with an event channel:
use tokio::sync::mpsc;
enum AppEvent {
Key(KeyEvent),
Tick,
DataUpdate(Vec<ProcessInfo>),
}
async fn event_loop(tx: mpsc::Sender<AppEvent>) {
let mut tick_interval = tokio::time::interval(Duration::from_millis(250));
loop {
tokio::select! {
_ = tick_interval.tick() => {
tx.send(AppEvent::Tick).await.unwrap();
}
Ok(true) = tokio::task::spawn_blocking(|| {
event::poll(Duration::from_millis(50)).unwrap_or(false)
}) => {
if let Ok(Event::Key(key)) = event::read() {
tx.send(AppEvent::Key(key)).await.unwrap();
}
}
}
}
}
Application Architecture
Real Ratatui applications need a clear architecture for managing state, input, and rendering. Here is a pattern that scales well.
The App Struct
pub struct App {
pub state: AppState,
pub mode: Mode,
pub tabs: Vec<Tab>,
pub active_tab: usize,
pub should_quit: bool,
}
pub enum Mode {
Normal,
Editing,
Searching,
}
pub enum AppState {
Dashboard(DashboardState),
Detail(DetailState),
Help,
}
pub struct DashboardState {
pub processes: Vec<ProcessInfo>,
pub table_state: TableState,
pub cpu_history: Vec<f64>,
pub memory_history: Vec<f64>,
pub last_update: Instant,
}
Separating Concerns
Split your application into three clear modules:
src/
main.rs # Setup, teardown, main loop
app.rs # Application state and logic
ui.rs # Rendering functions
event.rs # Event handling and input mapping
The rendering module contains pure functions that take a reference to the app state and a frame:
// ui.rs
pub fn render(frame: &mut Frame, app: &App) {
match &app.state {
AppState::Dashboard(state) => render_dashboard(frame, state, app.active_tab),
AppState::Detail(state) => render_detail(frame, state),
AppState::Help => render_help(frame),
}
}
fn render_dashboard(frame: &mut Frame, state: &DashboardState, active_tab: usize) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(frame.area());
render_tabs(frame, active_tab, chunks[0]);
render_body(frame, state, chunks[1]);
render_status_bar(frame, state, chunks[2]);
}
Building a Dashboard Application
Let us put it all together with a system monitoring dashboard that displays CPU usage, memory usage, process list, and network activity.
Data Model
use sysinfo::{System, Pid};
pub struct SystemMonitor {
sys: System,
cpu_history: Vec<f64>,
mem_history: Vec<f64>,
max_history: usize,
}
impl SystemMonitor {
pub fn new() -> Self {
let mut sys = System::new_all();
sys.refresh_all();
Self {
sys,
cpu_history: Vec::new(),
mem_history: Vec::new(),
max_history: 120, // 2 minutes at 1 sample/sec
}
}
pub fn update(&mut self) {
self.sys.refresh_all();
let cpu = self.sys.global_cpu_usage() as f64;
self.cpu_history.push(cpu);
if self.cpu_history.len() > self.max_history {
self.cpu_history.remove(0);
}
let mem = self.sys.used_memory() as f64 / self.sys.total_memory() as f64 * 100.0;
self.mem_history.push(mem);
if self.mem_history.len() > self.max_history {
self.mem_history.remove(0);
}
}
pub fn processes(&self) -> Vec<ProcessInfo> {
self.sys.processes()
.iter()
.map(|(pid, proc_)| ProcessInfo {
pid: pid.as_u32(),
name: proc_.name().to_string_lossy().to_string(),
cpu: proc_.cpu_usage() as f64,
memory: proc_.memory() / 1024, // KB
})
.collect()
}
}
Dashboard Rendering
fn render_dashboard(frame: &mut Frame, monitor: &SystemMonitor, table_state: &mut TableState) {
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Title
Constraint::Length(10), // Charts
Constraint::Min(0), // Process table
Constraint::Length(1), // Help bar
])
.split(frame.area());
// Title bar
frame.render_widget(
Paragraph::new(" System Monitor")
.style(Style::default().fg(Color::White).bg(Color::DarkGray)),
main_layout[0],
);
// Charts: CPU and Memory side by side
let chart_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(main_layout[1]);
render_cpu_chart(frame, &monitor.cpu_history, chart_layout[0]);
render_memory_chart(frame, &monitor.mem_history, chart_layout[1]);
// Process table
render_process_table(frame, &monitor.processes(), table_state, main_layout[2]);
// Help bar
frame.render_widget(
Paragraph::new(" q: Quit | j/k: Navigate | Enter: Details | /: Search")
.style(Style::default().fg(Color::DarkGray)),
main_layout[3],
);
}
Comparison with Alternatives
| Feature | Ratatui (Rust) | Textual (Python) | Bubbletea (Go) | Ink (JavaScript) |
|---|---|---|---|---|
| Language | Rust | Python | Go | JavaScript/React |
| Rendering model | Immediate mode | Retained mode (CSS-like) | Elm architecture | React reconciliation |
| Layout system | Constraints | CSS-like (flexbox, grid) | Manual | Flexbox |
| Built-in widgets | 15+ | 30+ | Few (community) | React components |
| Performance | Excellent | Good | Excellent | Good |
| Learning curve | Moderate (Rust) | Low (Python + CSS) | Low-Moderate | Low (if you know React) |
| Async support | Via tokio | Built-in | Built-in (goroutines) | Built-in (Node.js) |
| Testing | Unit tests on state | Snapshot testing | Unit tests on model | Jest/snapshot |
| Best for | High-perf tools, system utils | Data apps, dashboards | CLI tools, devtools | Quick prototypes |
Textual is the easiest to get started with if you know Python and CSS. It has the richest widget library and a CSS-like styling system. The trade-off is runtime performance and binary distribution -- Python applications are harder to distribute as single binaries.
Bubbletea uses the Elm architecture (Model-Update-View) which is clean and predictable. It is a great choice for Go developers and has a strong ecosystem of community components (Bubbles, Lip Gloss for styling). The lack of built-in complex widgets means you build more from scratch.
Ink lets you build terminal UIs with React and JSX. If your team already knows React, the learning curve is minimal. It is best for simpler interfaces -- complex, high-performance UIs are better served by Ratatui or Bubbletea.
Ratatui is the best choice when you need maximum performance, want to distribute a single static binary, or are already working in the Rust ecosystem. The immediate-mode model and Rust's type system make it easy to build correct, reliable UIs.
Testing Ratatui Applications
Because Ratatui uses an immediate-mode model, testing is straightforward. Your rendering functions are pure: given a state, they produce a UI. You can test them with Ratatui's built-in test backend:
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
#[test]
fn test_dashboard_renders() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let app = App::new();
terminal.draw(|frame| render(frame, &app)).unwrap();
// Assert specific cells contain expected content
let buffer = terminal.backend().buffer().clone();
assert!(buffer.content().iter().any(|cell| cell.symbol() == "System Monitor"));
}
}
For integration testing, you can also use insta for snapshot testing -- render the UI to a buffer and compare against a saved snapshot.
Publishing Your Application
One of Rust's strengths is producing self-contained binaries. A Ratatui application compiles to a single executable with no runtime dependencies:
# Build release binary
cargo build --release
# Cross-compile for Linux (from macOS)
cargo install cross
cross build --release --target x86_64-unknown-linux-musl
# The binary at target/release/my-tui-app is ready to distribute
Publish to crates.io for Rust users, or distribute the binary directly through GitHub Releases, Homebrew taps, or system package managers.
When to Build a TUI
Terminal UIs shine in specific contexts: developer tools where the user is already in a terminal, system administration utilities, monitoring dashboards displayed on always-on screens, tools used over SSH connections, and applications that need to run in minimal environments without a display server.
If your users expect a graphical interface, want mouse-driven interactions, or need rich media display, a TUI is the wrong choice. But for the right use case, a well-built TUI application is faster to launch, lighter on resources, and more universally accessible than any GUI or web application.
Ratatui gives you the tools to build that application in Rust with confidence. The immediate-mode architecture keeps your code simple. The widget library covers common patterns. And the Rust compiler ensures that if it compiles, it will not crash at runtime with a null pointer or use-after-free. For terminal UI development, it is the best framework available today.