How to build TUI with ratatui

Cli
Initialize a terminal backend with crossterm and render widgets inside a loop using ratatui to create a responsive text user interface.

Building a TUI with ratatui

You are building a system monitor. top works, but you want a dashboard with colored borders, a real-time graph, and a layout that updates every second without flickering. Or you are making a text-based game where the map needs to redraw instantly when the player moves. You need a Terminal User Interface. You do not want to fight escape codes manually. You want ratatui.

ratatui is the standard rendering library for Rust TUIs. It gives you widgets, layouts, and styling. It does not handle input or terminal control on its own. It relies on a backend to talk to the terminal. The community convention is to pair ratatui with crossterm. You will see them together in almost every project.

Think of ratatui as a stage director. You tell the director what actors go where and how they should look. The director shouts cues to the stage crew, who moves the props and lights. The crew is crossterm. You write the script (your app logic), the director handles the rendering, and the crew handles the terminal.

The render loop pattern

A TUI is not a script that runs and exits. It is a loop. The loop polls for events, updates state, and redraws the screen. This pattern keeps the interface responsive. If you block on input, the screen freezes. If you draw without checking for input, the user cannot quit.

The render loop has three parts:

  1. Setup the terminal and backend.
  2. Loop: poll events, update state, draw frame.
  3. Teardown and restore the terminal.

Setup and teardown are critical. Terminals have state. If your app crashes or exits without cleanup, the user might be left in raw mode where keys do not echo, or stuck in an alternate screen buffer. Always restore the terminal.

use crossterm::{
    event::{self, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    widgets::{Block, Paragraph},
    Frame, Terminal,
};
use std::io;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Enable raw mode so keys are captured instantly without Enter
    enable_raw_mode()?;
    
    // Switch to alternate screen to clear the view and protect user history
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    
    // Create the backend that talks to the terminal
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // The render loop
    loop {
        // Draw the current frame
        terminal.draw(|f| {
            let block = Block::default().title("Hello");
            let paragraph = Paragraph::new("World").block(block);
            // Render the widget to the full area
            f.render_widget(paragraph, f.area());
        })?;

        // Handle input
        if let Event::Key(key) = event::read()? {
            if key.code == KeyCode::Esc {
                break;
            }
        }
    }

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

Treat the alternate screen like a sandbox. When you leave, the world must look exactly as you found it.

How the frame works

terminal.draw takes a closure. Inside that closure, you get a Frame. The frame is a double buffer. You draw widgets to the frame, and ratatui calculates the changes. When the closure returns, ratatui flushes the changes to the backend. This prevents flicker. You never draw directly to the terminal. You always draw to the frame.

The frame also handles layout constraints. Widgets do not know their size until they are rendered. The frame tells each widget how much space it has. This allows flexible layouts. A Paragraph can wrap text based on the available width. A List can calculate how many items fit.

If you try to move a mutable reference into the draw closure, the compiler rejects you with E0502 (cannot borrow as mutable because it is also borrowed as immutable). The closure captures by reference by default. If you need to mutate state, do it outside the draw call. Keep the draw closure read-only. It is the heartbeat of your UI. Do not make it run a marathon.

Realistic app structure

The minimal example works, but real apps need state. You want to separate rendering from logic. A common pattern is an App struct that holds state and methods for running and rendering.

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

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

impl App {
    fn new() -> Self {
        Self {
            should_quit: false,
            count: 0,
        }
    }

    fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>) -> io::Result<()> {
        loop {
            // Draw first to show initial state
            terminal.draw(|f| self.render(f))?;
            
            // Poll for events with a timeout to keep UI responsive
            if event::poll(std::time::Duration::from_millis(100))? {
                if let Event::Key(key) = event::read()? {
                    match key.code {
                        KeyCode::Char('q') => self.should_quit = true,
                        KeyCode::Char(' ') => self.count += 1,
                        _ => {}
                    }
                }
            }
            
            if self.should_quit {
                break;
            }
        }
        Ok(())
    }

    fn render(&self, frame: &mut Frame) {
        // Split the screen vertically
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            // Top area takes minimum 1 line, bottom takes fixed 3 lines
            .constraints([Constraint::Min(1), Constraint::Length(3)].as_ref())
            .split(frame.area());

        // Render top content
        let text = format!("Count: {} (Press Space to increment)", self.count);
        let paragraph = Paragraph::new(text)
            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
        frame.render_widget(paragraph, chunks[0]);

        // Render bottom help block
        let help = Paragraph::new(Spans::from(vec![
            Span::styled("Controls: ", Style::default().add_modifier(Modifier::BOLD)),
            Span::raw("Space: Increment, Q: Quit"),
        ]))
        .block(Block::default().borders(Borders::ALL).title(" Help "));
        frame.render_widget(help, chunks[1]);
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let mut app = App::new();
    app.run(&mut terminal)?;

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

This structure scales. You can add more state fields, more widgets, and more input handling. The render method stays clean. The run method handles the loop. The main function handles setup and teardown.

Poll your events. A blocking read turns your TUI into a statue.

Layout and constraints

Layout is the workhorse of ratatui. It splits an area into chunks based on constraints. Constraints define how space is distributed.

  • Constraint::Length(n): Fixed size. Always takes n lines or columns.
  • Constraint::Min(n): Minimum size. Takes at least n, but can grow to fill space.
  • Constraint::Max(n): Maximum size. Takes up to n, but can shrink.
  • Constraint::Percentage(n): Percentage of remaining space.

Real apps use Min for content areas. Min allows the layout to adapt when the terminal resizes. Length is useful for fixed headers or footers. Percentage works for split panes.

If you mix constraints incorrectly, the layout might not behave as expected. ratatui calculates the distribution in order. If you have two Min constraints, the extra space is distributed proportionally. If you have a Length and a Min, the Length takes its fixed amount, and the Min takes the rest.

The community convention is to use as_ref() on constraint slices. It avoids cloning and keeps the code concise.

Pitfalls and errors

Blocking the render loop

The biggest mistake is calling event::read() without polling. event::read() blocks until input arrives. If you do this, the loop stops drawing. The UI freezes. The user presses a key, the app wakes up, processes the key, and freezes again.

Always use event::poll() with a timeout. The timeout determines how often the UI updates. A timeout of 100 milliseconds gives a smooth 10 FPS update rate. Adjust based on your needs.

Forgetting cleanup

If your app panics or exits early, the terminal might stay in raw mode. The user types, but nothing appears. Or the cursor disappears.

Use a cleanup function or a Drop implementation to restore the terminal. crossterm provides macros and helpers, but a simple function works. Call it in a defer pattern or at the end of main.

If you try to restore the terminal after dropping the backend, you get a borrow error. The backend holds the stdout handle. Drop the terminal first, then restore the terminal state.

Borrow checker in draw closure

The draw closure borrows the frame. If you try to mutate app state inside the closure, you hit E0502. The closure captures &self, but you cannot mutate self while borrowing it.

Keep state mutation outside the draw call. Update state in the event handling section, then draw. This separation is intentional. It keeps rendering predictable.

If you need to share state between threads, use Arc<Mutex<T>> or Arc<RwLock<T>>. ratatui runs on the main thread, but you might have background tasks. Wrap shared state in synchronization primitives.

Type mismatches

Widgets expect specific types. Paragraph::new takes Text, which can be a &str, String, Spans, or Vec<Spans>. If you pass a u32, you get E0277 (trait bound not satisfied).

Convert values to strings or spans before passing them to widgets. format!() is your friend.

Decision matrix

Use ratatui with crossterm when you need a full-screen TUI with widgets, layouts, and real-time updates. Use ratatui when you want a responsive interface that handles resizing and complex rendering.

Use dialoguer when you just need a quick prompt, menu, or input dialog and do not want a full render loop. Use dialoguer for simple interactive CLI tools where a TUI is overkill.

Use raw crossterm when you need low-level control over escape codes and do not want the overhead of a widget library. Use raw crossterm for custom rendering engines or when you need to manipulate the terminal in ways widgets do not support.

Use a GUI framework like egui or iced when you need windows, mouse support, or to run outside the terminal. Use a GUI framework when your app requires rich graphics or desktop integration.

Pick the tool that matches the complexity. Do not build a render loop to ask for a name.

Where to go next