How to Build a TUI Application in Rust with ratatui

Cli
You build a TUI in Rust by adding the `ratatui` and `crossterm` crates, initializing the terminal, and running a render loop that updates a `Frame`.

Taming the terminal with ratatui

You're building a dashboard for a server. println! floods the console, scrolls away, and makes the output unreadable. You need a window that redraws in place, updates stats, and responds to keystrokes. You find ratatui. You copy the example, run it, and your terminal gets stuck in raw mode, or the compiler screams about borrowed values. TUIs in Rust feel magical until you hit the terminal state management wall.

ratatui is the standard rendering engine for terminal user interfaces in Rust. It doesn't talk to the terminal directly. It draws to an abstract canvas. You need a backend to bridge that canvas to the actual terminal emulator. crossterm is the backend that handles the plumbing: raw mode, alternate screens, key events, and cursor control.

Think of ratatui as a painter who only works on a digital canvas. crossterm is the projector that beams that canvas onto your screen and catches your keystrokes. You need the painter for the art, and the projector for the audience. ratatui is the active community fork of the original tui-rs crate. The API is nearly identical, but ratatui is where the development and bug fixes happen. Use ratatui for new projects.

The rendering loop

A TUI application follows a strict lifecycle. You initialize the terminal, enter a loop that renders the UI and handles input, and then restore the terminal on exit. The loop drives everything. If the loop stops, the app freezes. If the loop runs too fast, you waste CPU cycles. If you forget to restore the terminal, you leave the user with a broken shell.

The core of the loop is terminal.draw. This method takes a closure that receives a Frame. The Frame is a temporary view of the screen buffer. You render widgets to the Frame. When the closure returns, ratatui flushes the buffer to the backend. The Frame lives only for the duration of the draw call. You can't store a reference to it. This design keeps the borrow checker happy and prevents race conditions in the rendering pipeline.

use crossterm::{
    cursor::Show,
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    layout::Rect,
    style::Style,
    widgets::Paragraph,
    Frame,
    Terminal,
};
use std::io;

fn main() -> io::Result<()> {
    // 1. Lock down terminal state for immediate input and clean output
    let mut stdout = io::stdout();
    enable_raw_mode()?;
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;

    // 2. Create backend and terminal
    // Terminal takes ownership of the backend. You can't use stdout after this.
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // 3. Main loop: render, then wait for input
    loop {
        terminal.draw(|f| ui(f))?;
        if let Event::Key(key) = event::read()? {
            if key.code == KeyCode::Char('q') {
                break;
            }
        }
    }

    // 4. Restore terminal state
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
    execute!(terminal.backend_mut(), Show)?;
    Ok(())
}

fn ui(frame: &mut Frame) {
    // frame.area() returns the full screen dimensions
    let area = frame.area();
    let paragraph = Paragraph::new("Hello TUI!").style(Style::default());
    frame.render_widget(paragraph, area);
}

The setup phase is critical. enable_raw_mode tells the OS to stop buffering input. You get keys the instant they hit, without waiting for Enter. EnterAlternateScreen switches to a hidden buffer. This clears the scrollback and gives your app a clean slate. When you leave the alternate screen, the original content reappears. EnableMouseCapture lets you read mouse events if you need them.

Terminal::new consumes the backend. This is a hard move. The terminal now owns the output stream. If you try to println! after creating the terminal, the compiler rejects you with E0382 (use of moved value). The terminal is the sole owner of the connection to the screen.

The loop calls draw, then event::read. event::read blocks until input arrives. This is a synchronous loop. It waits for a key, then breaks. For simple apps, this is fine. For apps that need to update based on time or async events, you'll need event::poll to check for input without blocking.

The teardown phase restores the terminal. disable_raw_mode returns input buffering to normal. LeaveAlternateScreen switches back to the main buffer. Show makes the cursor visible. If you skip this, the user is left with a terminal that doesn't echo keys and has no cursor. Treat the terminal as a borrowed resource. If you crash, you owe the user a working shell.

Realistic app structure

Real apps have state. They update based on events. They split the screen into regions. The community convention is to use an App struct to hold all state. This keeps data centralized and makes it easy to pass to the render function. The App struct usually has a running flag and whatever data your UI needs.

use crossterm::{
    cursor::Show,
    event::{self, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Layout},
    style::{Color, Style},
    widgets::{Block, Borders, Paragraph},
    Frame,
    Terminal,
};
use std::io;

struct App {
    count: u32,
    running: bool,
}

impl App {
    fn new() -> Self {
        App { count: 0, running: true }
    }

    fn handle_key(&mut self, key: KeyCode) {
        match key {
            KeyCode::Char('q') => self.running = false,
            KeyCode::Char('j') => self.count += 1,
            KeyCode::Char('k') => {
                if self.count > 0 {
                    self.count -= 1;
                }
            }
            _ => {}
        }
    }
}

fn main() -> io::Result<()> {
    let mut stdout = io::stdout();
    enable_raw_mode()?;
    execute!(stdout, EnterAlternateScreen)?;

    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let mut app = App::new();

    while app.running {
        terminal.draw(|f| ui(f, &app))?;
        if let Event::Key(key) = event::read()? {
            app.handle_key(key.code);
        }
    }

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    execute!(terminal.backend_mut(), Show)?;
    Ok(())
}

fn ui(frame: &mut Frame, app: &App) {
    let area = frame.area();

    // Split screen vertically: header takes 3 lines, rest is flexible
    let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area);

    // Header block with title and styling
    let header = Paragraph::new("Counter App")
        .style(Style::default().fg(Color::Yellow).bold())
        .block(Block::default().borders(Borders::ALL).title("Header"));
    frame.render_widget(header, chunks[0]);

    // Main content area
    let text = format!(
        "Count: {}\n\nPress 'j' to increment\nPress 'k' to decrement\nPress 'q' to quit",
        app.count
    );
    let content = Paragraph::new(text)
        .style(Style::default().fg(Color::White))
        .block(Block::default().borders(Borders::ALL).title("Content"));
    frame.render_widget(content, chunks[1]);
}

The App struct holds count and running. handle_key updates state based on input. The ui function takes &App and renders widgets. Layout::vertical splits the screen. Constraint::Length(3) gives the header exactly 3 lines. Constraint::Min(1) gives the rest to the content. Block adds borders and titles. Style sets colors and modifiers.

This structure scales. You can add more fields to App. You can add more keys to handle_key. You can add more widgets to ui. The render function stays pure. It reads state and draws. It doesn't modify state. This separation makes the code easier to reason about. Keep the render function fast. The terminal can't redraw faster than your code can paint.

Pitfalls and compiler errors

Terminal state corruption is the biggest risk. If your app panics, disable_raw_mode never runs. The terminal stays in raw mode. You can't type normally. The cursor vanishes. The community convention is to wrap the entire app logic in crossterm::terminal::initialize. This macro sets up a panic hook that restores the terminal even if the code explodes.

use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};

fn main() -> io::Result<()> {
    // initialize wraps setup and teardown in a closure
    // It installs a panic hook to restore the terminal on crash
    terminal::initialize(|stdout| {
        let backend = CrosstermBackend::new(stdout);
        let mut terminal = Terminal::new(backend)?;
        let mut app = App::new();

        while app.running {
            terminal.draw(|f| ui(f, &app))?;
            if let Event::Key(key) = event::read()? {
                app.handle_key(key.code);
            }
        }
        Ok(())
    })?;
    Ok(())
}

initialize takes a closure. It handles enable_raw_mode, EnterAlternateScreen, and the panic hook. Inside the closure, you create the terminal and run the loop. When the closure returns, initialize handles teardown. If a panic occurs, the hook runs and restores the terminal. Use this pattern for any real app. It saves users from manual terminal recovery.

Borrow checker errors are common when mixing terminal access. terminal.draw borrows terminal mutably. You can't hold a mutable reference to the backend while drawing. If you try to call terminal.backend_mut() inside the draw closure, you get E0502 (cannot borrow as mutable because it is also borrowed as immutable). The Frame is the only way to interact with the screen during draw. Don't try to bypass it.

Blocking input is another trap. event::read blocks until input arrives. If you want to update the UI based on time, or poll for events, use event::poll. poll takes a timeout and returns immediately. You can check for events without blocking the render loop. This is essential for apps that need to update charts, progress bars, or live data.

use crossterm::event::{self, Event, KeyEventKind};
use std::time::Duration;

fn main_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> {
    while app.running {
        terminal.draw(|f| ui(f, app))?;

        // Poll for events with a 100ms timeout
        if event::poll(Duration::from_millis(100))? {
            if let Event::Key(key) = event::read()? {
                // Ignore release events to avoid double processing
                if key.kind == KeyEventKind::Press {
                    app.handle_key(key.code);
                }
            }
        } else {
            // Timeout elapsed, update time-based state here
            app.update_tick();
        }
    }
    Ok(())
}

poll returns true if an event is ready. event::read fetches it. KeyEventKind::Press filters out release events. Terminals send both press and release. You usually only care about press. This pattern gives you control over the loop timing. You can update state on every tick, or only when events arrive.

When to use ratatui

Use ratatui when you need a rich, widget-based interface with layouts, tables, charts, and popups. It handles the heavy lifting of positioning and styling. Use crossterm alone when you only need raw terminal control, like moving the cursor, changing colors, or reading keys, without the overhead of a rendering engine. Use indicatif when you just need a progress bar or a spinner for a CLI tool. It's lighter and focused on that specific job. Use a GUI framework like egui or iced when you need windows, drag-and-drop, or complex interactions that terminals can't support.

ratatui shines when you have multiple regions, dynamic content, and user interaction. It's the right tool for dashboards, file managers, and interactive tools. It's overkill for a simple progress bar. Pick the tool that matches the complexity of your UI.

Where to go next