Terminal User Interfaces in Rust With Ratatui Step by Step
Build a terminal user interface with Ratatui: init and restore, the draw loop, widgets and layout, handling key events and state, with a small counter and list app.

A terminal user interface looks like a hard problem because it takes over the whole screen, reacts to single keypresses, and redraws many times a second. Underneath, it is a loop with three steps: wait for an event, change some state, draw the state. Rust is a good fit because the state is a plain struct, the event type is an enum, and the compiler checks that every event is handled. ratatui is the library that does the drawing, and crossterm is the library underneath it that talks to the terminal.
This guide builds a small interactive list from nothing, in the order that makes each piece testable: first the state and the drawing, which you can test without a terminal, then the event loop and the terminal setup, then the failures that leave a terminal garbled. Everything compiled and ran on Rust 1.99 with edition 2024, ratatui 0.30.2 and crossterm 0.29.0. The screen shown below is the real buffer the code rendered.
What a terminal program actually does
Three facts explain nearly every TUI bug.
A normal terminal is in cooked mode: the operating system buffers your typing until Enter, echoes each character and handles Ctrl-C itself. A TUI switches the terminal to raw mode, where each keypress is delivered to your program immediately and nothing is echoed. Raw mode is a property of the terminal, not of your process, so if your program exits without switching back, the user's shell is left in raw mode, which looks like a shell that does not echo what you type.
A TUI also draws on the alternate screen, a second buffer the terminal keeps so that when you quit, the user's previous scrollback comes back untouched. Like raw mode, entering it is a request you must undo.
Finally, ratatui is an immediate-mode library. You do not create widget objects that live between frames and update them. On every frame you describe the whole screen again from your state, and the library diffs the new frame against the previous one and sends only the changed cells to the terminal. This sounds wasteful and is the opposite: it removes a whole class of bugs where the screen and the state disagree, because the screen is always a pure function of the state.
Put the state in a struct
Design the state before the drawing. For a todo list, the state is the items, which are done, and which row is selected. ratatui offers ListState to track the selection and scroll offset.
use ratatui::backend::TestBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph};
use ratatui::{Frame, Terminal};
#[derive(Debug, Default)]
struct App {
items: Vec<String>,
selected: ListState,
done: Vec<bool>,
}
impl App {
fn new(items: &[&str]) -> Self {
let mut selected = ListState::default();
selected.select(Some(0));
Self { items: items.iter().map(|s| s.to_string()).collect(), selected, done: vec![false; items.len()] }
}
fn next(&mut self) {
let i = self.selected.selected().map_or(0, |i| (i + 1) % self.items.len());
self.selected.select(Some(i));
}
fn toggle(&mut self) {
if let Some(i) = self.selected.selected() {
self.done[i] = !self.done[i];
}
}
}
fn draw(frame: &mut Frame, app: &mut App) {
let [body, footer] = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(frame.area());
let rows: Vec<ListItem> = app
.items
.iter()
.zip(&app.done)
.map(|(text, done)| ListItem::new(format!("[{}] {text}", if *done { "x" } else { " " })))
.collect();
let list = List::new(rows)
.block(Block::bordered().title("Todo"))
.highlight_style(Style::new().add_modifier(Modifier::REVERSED))
.highlight_symbol("> ");
frame.render_stateful_widget(list, body, &mut app.selected);
frame.render_widget(Paragraph::new("j/k move space toggle q quit"), footer);
}
fn main() -> anyhow::Result<()> {
let mut terminal = Terminal::new(TestBackend::new(34, 7))?;
let mut app = App::new(&["write the parser", "add tests", "ship it"]);
app.next();
app.toggle();
terminal.draw(|f| draw(f, &mut app))?;
let buffer = terminal.backend().buffer().clone();
for y in 0..buffer.area.height {
let line: String = (0..buffer.area.width).map(|x| buffer[(x, y)].symbol().to_string()).collect();
println!("{}", line.trim_end());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_wraps_around() {
let mut app = App::new(&["a", "b"]);
app.next();
assert_eq!(app.selected.selected(), Some(1));
app.next();
assert_eq!(app.selected.selected(), Some(0));
}
#[test]
fn toggle_flips_only_the_selected_row() {
let mut app = App::new(&["a", "b", "c"]);
app.next();
app.toggle();
assert_eq!(app.done, [false, true, false]);
}
#[test]
fn rendered_buffer_marks_the_selection() {
let mut terminal = Terminal::new(TestBackend::new(30, 6)).unwrap();
let mut app = App::new(&["only"]);
terminal.draw(|f| draw(f, &mut app)).unwrap();
let first_row: String = (0..30).map(|x| terminal.backend().buffer()[(x, 1)].symbol().to_string()).collect();
assert!(first_row.contains("> [ ] only"), "got: {first_row:?}");
}
}The program renders to a TestBackend, an in-memory screen, so it needs no real terminal and its output is deterministic. This is what it printed:
┌Todo────────────────────────────┐
│ [ ] write the parser │
│> [x] add tests │
│ [ ] ship it │
│ │
└────────────────────────────────┘
j/k move space toggle q quitThe App struct has two methods that change state, next and toggle, and neither knows anything about the terminal. That separation is the most valuable decision in the design: the logic can be tested with plain assert_eq!, as the three tests at the bottom of the file do, and you can run them with cargo test --bin q529 without opening a terminal.
Reading the draw function
draw takes the frame and the app and builds the screen in three parts.
The layout comes first. Layout::vertical([Constraint::Min(3), Constraint::Length(1)]) splits the area into a body that takes whatever is left (but at least three rows) and a footer that is exactly one row. The .areas(frame.area()) call destructures the result into two Rect values, and because the array length is known at compile time, forgetting a constraint is a type error rather than an index panic.
The list is built from the state. Each row is a ListItem formatted from the item text and its done flag. Block::bordered().title("Todo") draws the frame, highlight_style reverses the colors of the selected row, and highlight_symbol("> ") puts a marker in front of it. The highlight style does not show in the plain-text dump above because colors are stored in the buffer cells' style rather than the symbols; the > marker is what shows.
The last line renders the footer. A footer that lists the keys costs one row and saves every user a trip to the README.
Why the stateful widget needs a mutable reference
render_stateful_widget takes &mut app.selected because rendering can adjust the list's scroll offset to keep the selection visible. So draw must take &mut App. If you write app: &App the compiler stops you, and the message is clear about why:
error[E0596]: cannot borrow `app.selected` as mutable, as it is behind a `&` reference
--> src/bin/q529x.rs:46:46
|
46 | frame.render_stateful_widget(list, body, &mut app.selected);
| ^^^^^^^^^^^^^^^^^ `app` is a `&` reference, so it cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference
|
33 | fn draw(frame: &mut Frame, app: &mut App) {
| +++The fix is the one the compiler suggests. Notice that the borrow of app.items earlier in the function and the mutable borrow of app.selected coexist without trouble, because they are different fields. Splitting state into separate fields rather than one big struct behind one lock is what lets the borrow checker allow it.
The event loop and terminal setup
With state and drawing working, the real program adds the loop that connects them to a keyboard. It is short, and every line matters.
use std::io;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::DefaultTerminal;
use ratatui::widgets::Paragraph;
fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
let mut count = 0u32;
loop {
terminal.draw(|frame| {
frame.render_widget(Paragraph::new(format!("count: {count} (up/down, q to quit)")), frame.area());
})?;
// Wake at least every 250 ms so a timer or a background update can redraw.
if !event::poll(Duration::from_millis(250))? {
continue;
}
if let Event::Key(key) = event::read()? {
// Windows reports both press and release; act on the press only.
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Up => count += 1,
KeyCode::Down => count = count.saturating_sub(1),
_ => {}
}
}
}
}
fn main() -> io::Result<()> {
// init() enters raw mode and the alternate screen, and installs a panic hook
// that restores the terminal before the panic message prints.
let mut terminal = ratatui::init();
let result = run(&mut terminal);
ratatui::restore();
result
}ratatui::init() does the setup work: it enables raw mode, switches to the alternate screen, and installs a panic hook that restores the terminal before the panic message prints. That last part is the one people forget when they set up the terminal by hand. Without it, a panic prints its message into the alternate screen, which disappears when the program exits, and leaves the shell in raw mode; the user sees nothing and has to type reset blind.
main calls ratatui::restore() after run returns, whether it returned Ok or Err, and only then returns the result. Doing it in that order matters: if you used ? on run directly, an error would skip the restore.
Poll with a timeout, not a blocking read
event::read() blocks until a key arrives, which is fine for a program that changes only when the user acts. A program with a clock, a progress bar or background work needs to redraw without input, so use event::poll(Duration::from_millis(250)) first. It returns false after the timeout, the loop continues, and terminal.draw runs again with whatever state changed in the meantime. The timeout is also your frame rate floor for animation: 250 ms redraws four times a second when idle, and you can shorten it while something is moving.
Press, repeat and release
On Windows, and on terminals that support the Kitty keyboard protocol, crossterm reports key release events as well as presses. If your loop acts on every Event::Key, each tap on Windows moves the selection twice. The key.kind != KeyEventKind::Press check in the loop above prevents it. It is a bug that appears only on the platform you did not test on, which is why it is worth writing the guard on day one.
Layout: constraints instead of coordinates
Never compute pixel offsets by hand. ratatui splits a rectangle by constraints and solves them, so a layout that works at 80 columns still works at 200 and degrades sensibly at 40. The constraints you will use most:
| Constraint | Meaning | Typical use |
|---|---|---|
Length(n) | Exactly n cells | A one-row footer, a fixed sidebar |
Min(n) | At least n, grows to fill | The main body |
Max(n) | At most n | A panel that should not stretch |
Percentage(p) | p percent of the available space | A 30 percent sidebar |
Fill(weight) | A share of what is left, by weight | Two equal columns, or 1:2 |
A two-pane layout is a horizontal split of the body: Layout::horizontal([Constraint::Percentage(30), Constraint::Fill(1)]). Nest layouts for anything more complex rather than flattening everything into one. Always test the layout at a small size, since terminals shrink and a panel that needs 12 rows should hide or collapse instead of overflowing.
Test it like any other code
There are three layers of testing, and they get cheaper as you go down.
The cheapest is state logic: next wraps around, toggle flips only the selected row. These are ordinary unit tests with no ratatui in them.
The second is rendering through the TestBackend, as the third test in the listing does: draw into an in-memory buffer and assert on the cells. This catches layout regressions, such as the selection marker disappearing, and it runs in microseconds. For larger screens, snapshot the buffer with the insta crate so a change to the layout shows up as a reviewable diff of the text screen.
The third is the event loop, which is the part you cannot easily unit test because it reads from the real terminal. Keep it thin enough that it needs no test: it should translate a KeyCode into a method call on App and nothing else. A useful trick is to put the translation in a function fn on_key(app: &mut App, code: KeyCode) -> Control that you can test by calling it, leaving only the polling in the loop.
running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sKeep keys and meaning apart
The loop above matches keys inline. That is fine for four keys and painful for forty, because the same action tends to have several bindings (j and the down arrow), and the meaning of a key changes between screens. The structure that scales is to translate a key into an Action enum first, then apply the action to the state. The translation is a pure function, so you can test every binding.
use crossterm::event::KeyCode;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
use ratatui::Terminal;
/// A custom widget: a horizontal gauge with a label, drawn straight into the buffer.
struct Meter<'a> {
label: &'a str,
ratio: f64,
}
impl Widget for Meter<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let label = format!("{} {:>3.0}% ", self.label, self.ratio * 100.0);
let bar_width = area.width.saturating_sub(label.chars().count() as u16) as usize;
let filled = (bar_width as f64 * self.ratio.clamp(0.0, 1.0)).round() as usize;
let bar = format!("{}{}", "#".repeat(filled), ".".repeat(bar_width - filled));
buf.set_string(area.x, area.y, format!("{label}{bar}"), ratatui::style::Style::default());
}
}
/// What a key press means, decided without touching the terminal or the app.
#[derive(Debug, PartialEq)]
enum Action {
Quit,
MoveDown,
MoveUp,
Toggle,
None,
}
fn on_key(code: KeyCode) -> Action {
match code {
KeyCode::Char('q') | KeyCode::Esc => Action::Quit,
KeyCode::Char('j') | KeyCode::Down => Action::MoveDown,
KeyCode::Char('k') | KeyCode::Up => Action::MoveUp,
KeyCode::Char(' ') | KeyCode::Enter => Action::Toggle,
_ => Action::None,
}
}
fn main() -> anyhow::Result<()> {
assert_eq!(on_key(KeyCode::Char('j')), Action::MoveDown);
assert_eq!(on_key(KeyCode::Esc), Action::Quit);
assert_eq!(on_key(KeyCode::Char('x')), Action::None);
let mut terminal = Terminal::new(TestBackend::new(32, 3))?;
terminal.draw(|f| {
let a = f.area();
f.render_widget(Meter { label: "disk", ratio: 0.62 }, Rect::new(a.x, 0, a.width, 1));
f.render_widget(Meter { label: "mem", ratio: 0.18 }, Rect::new(a.x, 1, a.width, 1));
f.render_widget(Meter { label: "cpu", ratio: 1.4 }, Rect::new(a.x, 2, a.width, 1));
})?;
let buf = terminal.backend().buffer().clone();
for y in 0..3 {
let line: String = (0..32).map(|x| buf[(x, y)].symbol().to_string()).collect();
println!("{line}");
}
Ok(())
}The three assertions at the top of main are the test: j is MoveDown, Esc is Quit, an unbound key is None. When you add a command palette or user-configurable bindings later, only on_key changes; the code that applies actions stays as it was. When you add a modal screen such as a confirmation dialog, give the app a mode field and make on_key take the mode, so y means "yes" in the dialog and nothing in the list.
Write a widget when the built-ins stop fitting
The same file draws a custom widget, which shows how little the Widget trait demands: a single render method that writes cells into a Buffer inside an area. The output is the real buffer:
disk 62% ##############........
mem 18% ####...................
cpu 140% #######################Read the third line as a bug report. The label prints 140% because the label uses the raw ratio, while the bar clamps it to 100 percent, so the two disagree. A custom widget is plain code, and plain code has plain bugs; the TestBackend is how you see it before a user does. Clamp once at the top of render and use the clamped value for both. Also notice saturating_sub on the width: in a very narrow area the label can be wider than the space, and an unchecked subtraction on a u16 panics in debug builds and wraps in release, producing a bar a few thousand cells wide.
Redraw only what changed, and only when it changed
ratatui already diffs frames, so the cost of calling terminal.draw when nothing changed is building the widgets and comparing two buffers, not writing to the terminal. For most programs that is negligible and you can draw on every loop iteration. It stops being negligible when a frame is expensive to build, for example when it formats ten thousand log lines. Two techniques fix that. Keep a dirty flag that is set by any state change and cleared after a draw, and skip the draw when it is clear. And render only the visible slice of a long list: take items[offset..offset + height] rather than building a ListItem per row of the whole dataset. A list of a million rows is a list of fifty rows and an offset.
A path from here to a real application
The toy list becomes a real tool in a predictable order. Add a text input for filtering (a String in the state and a case for KeyCode::Char(c) in the input mode). Add a details pane that renders the selected item, which is another widget fed from the same state. Move slow work to a background task with a channel. Persist the state on exit so the tool reopens where you left it. Add a help overlay on ? listing the bindings, generated from the same table that on_key uses so the two cannot disagree. At each step, the discipline is the same as in the first listing: state in a struct, behavior in methods, drawing as a function of state, and a TestBackend assertion for every screen that matters.
Work that takes time
A TUI that runs a slow task inside the event loop freezes: no redraw, no key handling, until the task returns. The standard fix is to move the work to another thread or task and send results back over a channel that the loop checks. With the polling loop above, the channel check is one line in the loop body: drain rx.try_recv() and update the state, and the next draw shows the result. The state is only ever mutated on the UI thread, so there are no locks around it.
Do not print from the background work. Anything written to standard output or standard error while the alternate screen is up lands in the middle of your drawing and corrupts it, and the cells are not redrawn until their content changes. Send log lines through the channel into a log panel, or write logs to a file with tracing-appender and tail the file in another terminal. This is the single most common visual bug in a first TUI.
Mouse, resize and unicode
Three details affect how finished the program feels.
Resizing: the terminal sends a resize event, and ratatui recomputes frame.area() on the next draw, so layouts built from constraints adapt on their own. Make sure the loop redraws after a resize even if no key was pressed; the 250 ms poll above guarantees it.
Mouse: enable it explicitly with crossterm::event::EnableMouseCapture and read Event::Mouse. Capturing the mouse stops the terminal's own text selection, which users notice and dislike, so enable it only if clicks matter and tell them how to select text (many terminals use Shift-drag).
Width: not every character is one cell wide. CJK characters and many emoji occupy two cells, and combining marks occupy none. ratatui handles wide characters inside its own widgets, but if you pad or truncate strings yourself with format!("{:<20}"), you pad by characters rather than by cells and the columns drift. Use the unicode-width crate to measure.
Mistakes that corrupt the screen
- Forgetting to restore the terminal on an error path. Always restore in one place, as
maindoes here, and rely on the panic hook for the rest. - Printing with
println!or logging to stderr while the TUI is up. - Doing blocking I/O inside
draw. Draw must be a pure function of state; fetch data elsewhere and store it. - Assuming a fixed terminal size. Test at 40 by 10 and at 200 by 60.
- Reading
KeyCode::Char('Q')and'q'as the same key. The shifted key is a different character, so match both if you mean both. - Redrawing in a tight loop without a poll timeout, which pins a CPU core for a screen that has not changed.
When a TUI is the wrong interface
If the user runs your program once and wants an answer, a normal command with good output is better, and it composes with pipes, which a full-screen program does not. If the interaction is a few prompts, a line-based prompt library is lighter. If the program must run unattended or over a flaky connection, a TUI that needs a terminal of a given size and a keyboard is a liability. Choose a TUI when the user explores data interactively and the same screen updates as they do: a process monitor, a log browser, a database client, a file manager. For the command line side of the same toolbox, see a command line tool from clap to a shipped release.


