Ratatui
Ratatui is an immediate-mode Rust library for building terminal UIs. It renders the entire UI each frame from application state — there is no persistent widget tree. The default backend is Crossterm.
-
Crate: ratatui = "0.30"
-
MSRV: 1.86.0 (Rust 2024 edition)
-
Widget reference: Read references/widgets.md for built-in widget details, styling, and custom widget implementation
-
Architecture patterns: Read references/architecture.md for TEA, component, and monolithic patterns, event handling, layout, state management, and testing
Quick Start
Minimal app with ratatui::run() (v0.30+)
use ratatui::{widgets::{Block, Paragraph}, style::Stylize};
fn main() -> Result<(), Box<dyn std::error::Error>> { ratatui::run(|terminal| { loop { terminal.draw(|frame| { let greeting = Paragraph::new("Hello, Ratatui!") .centered() .yellow() .block(Block::bordered().title("Welcome")); frame.render_widget(greeting, frame.area()); })?; if crossterm::event::read()?.is_key_press() { break Ok(()); } } }) }
ratatui::run() calls init() before and restore() after the closure — handles terminal setup/teardown automatically.
App with init() /restore() (manual control)
fn main() -> Result<()> { color_eyre::install()?; let mut terminal = ratatui::init(); let result = run(&mut terminal); ratatui::restore(); result }
fn run(terminal: &mut ratatui::DefaultTerminal) -> Result<()> { loop { terminal.draw(|frame| { /* render widgets */ })?; if let Event::Key(key) = crossterm::event::read()? { if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { break; } } } Ok(()) }
Cargo.toml
[dependencies] ratatui = "0.30" crossterm = "0.29" color-eyre = "0.6"
Core Concepts
Rendering
Immediate-mode: call terminal.draw(|frame| { ... }) each tick. Build widgets from state and render — no retained widget tree.
terminal.draw(|frame| { frame.render_widget(some_widget, frame.area()); frame.render_stateful_widget(stateful_widget, area, &mut state); })?;
Layout
Use Layout to split areas with constraints. Prefer areas() for destructuring (v0.28+):
let [header, body, footer] = Layout::vertical([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ]).areas(frame.area());
Centering with Rect::centered() (v0.30+):
let popup_area = frame.area() .centered(Constraint::Percentage(60), Constraint::Percentage(40));
Or with Flex::Center :
let [area] = Layout::horizontal([Constraint::Length(40)]) .flex(Flex::Center) .areas(frame.area());
Constraint types: Length(n) , Min(n) , Max(n) , Percentage(n) , Ratio(a, b) , Fill(weight) .
Widgets
All widgets implement Widget trait (fn render(self, area: Rect, buf: &mut Buffer) ). Stateful widgets use StatefulWidget with an associated State type.
Built-in: Block , Paragraph , List , Table , Tabs , Gauge , LineGauge , BarChart , Chart , Canvas , Sparkline , Scrollbar , Calendar , Clear .
Text primitives: Span , Line , Text — all implement Widget .
See references/widgets.md for full API details.
Event Handling
Use Crossterm for input. Always check KeyEventKind::Press :
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { match key.code { KeyCode::Char('q') => should_quit = true, KeyCode::Up | KeyCode::Char('k') => scroll_up(), KeyCode::Down | KeyCode::Char('j') => scroll_down(), _ => {} } } }
Terminal Setup & Panic Handling
With ratatui::run() (simplest, v0.30+):
fn main() -> Result<(), Box<dyn std::error::Error>> { ratatui::run(|terminal| { /* app loop */ }) }
With color-eyre panic hook (recommended for init() /restore() ):
fn install_hooks() -> Result<()> { let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default().into_hooks(); let panic_hook = panic_hook.into_panic_hook(); std::panic::set_hook(Box::new(move |info| { ratatui::restore(); panic_hook(info); })); eyre_hook.install()?; Ok(()) }
Architecture
Choose based on complexity. See references/architecture.md for full patterns with code.
Complexity Pattern When
Simple Monolithic Single-screen, few key bindings, no async
Medium TEA (The Elm Architecture) Multiple modes, form-like interaction
Complex Component Multi-panel, reusable panes, plugin-like
TEA (The Elm Architecture) — Summary
struct Model { counter: i32, running: bool }
enum Message { Increment, Decrement, Quit }
fn update(model: &mut Model, msg: Message) { match msg { Message::Increment => model.counter += 1, Message::Decrement => model.counter -= 1, Message::Quit => model.running = false, } }
fn view(model: &Model, frame: &mut Frame) { let text = format!("Counter: {}", model.counter); frame.render_widget(Paragraph::new(text), frame.area()); }
Common Patterns
List Navigation with Selection
let mut list_state = ListState::default().with_selected(Some(0));
// Update match key.code { KeyCode::Up => list_state.select_previous(), KeyCode::Down => list_state.select_next(), _ => {} }
// Render let list = List::new(items) .block(Block::bordered().title("Items")) .highlight_style(Style::new().reversed()) .highlight_symbol(Line::from(">> ").bold()); frame.render_stateful_widget(list, area, &mut list_state);
Popup Overlay
fn render_popup(frame: &mut Frame, title: &str, content: &str) { let area = frame.area() .centered(Constraint::Percentage(60), Constraint::Percentage(40)); frame.render_widget(Clear, area); let popup = Paragraph::new(content) .block(Block::bordered().title(title).border_type(BorderType::Rounded)) .wrap(Wrap { trim: true }); frame.render_widget(popup, area); }
Tabbed Interface
let titles = vec!["Tab1", "Tab2", "Tab3"]; let tabs = Tabs::new(titles) .block(Block::bordered()) .select(selected_tab) .highlight_style(Style::new().bold().yellow()); frame.render_widget(tabs, tabs_area);
Custom Widget
struct StatusBar { message: String }
impl Widget for StatusBar { fn render(self, area: Rect, buf: &mut Buffer) { Line::from(self.message) .style(Style::new().bg(Color::DarkGray).fg(Color::White)) .render(area, buf); } }
// Implement for reference to avoid consuming the widget: impl Widget for &StatusBar { fn render(self, area: Rect, buf: &mut Buffer) { Line::from(self.message.as_str()) .style(Style::new().bg(Color::DarkGray).fg(Color::White)) .render(area, buf); } }
Text Input with tui-input
[dependencies] tui-input = "0.11"
use tui_input::Input; use tui_input::backend::crossterm::EventHandler;
let mut input = Input::default();
// In event handler: input.handle_event(&crossterm::event::Event::Key(key));
// In render: let width = area.width.saturating_sub(2) as usize; let scroll = input.visual_scroll(width); let input_widget = Paragraph::new(input.value()) .scroll((0, scroll as u16)) .block(Block::bordered().title("Search")); frame.render_widget(input_widget, area); frame.set_cursor_position(Position::new( area.x + (input.visual_cursor().max(scroll) - scroll) as u16 + 1, area.y + 1, ));
Key Conventions
-
Always restore terminal — even on panic. Use ratatui::run() or install a panic hook
-
Check KeyEventKind::Press on all key events
-
Use Block::bordered() as standard container
-
Prefer Layout::vertical/horizontal([...]).areas(rect) over .split(rect)
-
Use Clear widget before rendering popups/overlays
-
Implement Widget for &MyType when the widget should not be consumed on render
-
Use ListState , TableState , ScrollbarState for scroll/selection tracking
-
Prefer color-eyre for error handling in TUI apps
-
Use Rect::centered() (v0.30+) for centering layouts instead of double Flex::Center