← All articles
CLI TOOLS Ratatui: Terminal UI Framework for Rust 2026-02-15 · 10 min read · ratatui · rust · tui

Ratatui: Terminal UI Framework for Rust

CLI Tools 2026-02-15 · 10 min read ratatui rust tui terminal cli user-interface dashboard

Ratatui: Terminal UI Framework for Rust

Ratatui TUI architecture diagram showing the rendering pipeline from application state through widgets to terminal output

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:

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:

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

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.