Ratatui: Layout, Widgets & Styling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Ratatui draws by immediate-mode rendering: on every pass of the event loop the application describes the whole screen, and the library writes that description into a cell buffer which it then diffs against the previous frame. Describing the screen has three ingredients, and this page covers all three: where things go (Rect, Layout, Constraint), what the text looks like (Span, Line, Text, Style) and which widget draws each area (Block, List, Table and the rest). Installing Ratatui, the crate split, ratatui::run, DefaultTerminal and the first counter-style application are on Ratatui: Getting Started; events, async, application architecture and backends are on Ratatui: Events, Async & Application Architecture; testing a screen with TestBackend and the third-party widget ecosystem are on Ratatui: Testing & Ecosystem. The render pipeline that all TUI libraries share is on TUI Architecture & Concepts.

The page was written against Ratatui 0.30.2 and ratatui-widgets 0.3.2, as listed on docs.rs on 2026-10-25. Ratatui has not reached 1.0, so each 0.x release may change an API; compare with the official breaking-changes pages before upgrading. The running example is the shelf tui catalogue browser of the Bookshelf scenario: a tab bar, a list of books, a details pane, a loans table, a status line and a help popup. The Rust examples were not compiled while this page was written because no Rust toolchain was available on the machine used; every type, method and argument name in them was checked against the crates' documentation on docs.rs instead. Compile each snippet before relying on it.

The 0.30 crate layout in one paragraph

Since 0.30 the library is split into several crates, and the ratatui crate re-exports what applications need. ratatui-core holds the traits and types every widget library needs (Rect, Layout, Style, Buffer, Text, the Widget and StatefulWidget traits); ratatui-widgets holds the built-in widgets described below. An application imports everything from ratatui::layout, ratatui::style, ratatui::text and ratatui::widgets, so the split only matters if you write a widget library (depend on ratatui-core, which changes less often) or an no_std-style consumer. Crossterm is re-exported as ratatui::crossterm. Cargo features that matter on this page are listed in the table below.

Cargo feature Default Effect

widget-calendar (part of all-widgets)

yes

Enables the Monthly calendar widget, which needs the time crate for dates

layout-cache

yes

Caches the result of Layout splits; the cache size is set with Layout::init_cache

underline-color

yes

Enables Style::underline_color; implemented only by the crossterm backend

macros

yes

Re-exports the ratatui-macros crate as ratatui::macros

unstable-widget-ref

no

Enables the WidgetRef and StatefulWidgetRef traits and FrameExt; no stability guarantees

unstable-rendered-line-info

no

Enables Paragraph::line_count and Paragraph::line_width; no stability guarantees

palette, serde, scrolling-regions

no

Colour conversions with the palette crate, serialisation of the style types and scrolling-region optimisations

Rect: the unit of layout

Every widget is rendered into a Rect, a rectangle of terminal cells (not pixels) with the public u16 fields x, y, width and height. The origin (0, 0) is the top-left cell, x grows to the right and y grows downwards. Frame::area() returns the Rect of the whole terminal for the current frame; it is the same for the whole render pass, and it replaces the deprecated Frame::size().

Method What it does

Rect::new(x, y, width, height)

Const constructor; Rect::ZERO, Rect::MIN and Rect::MAX are associated constants

area(), is_empty()

Number of cells (u32) and whether the area is empty

left(), right(), top(), bottom()

The edges of the rectangle

inner(Margin), outer(Margin)

Shrink or grow by a margin; the standard way to put a widget inside a border drawn separately

offset(Offset), resize(Size)

Move or resize a copy

union(Rect), intersection(Rect), clamp(Rect)

Combine rectangles; clamp moves a rectangle so that it fits inside another

centered_horizontally(Constraint), centered_vertically(Constraint), centered(Constraint, Constraint)

Centre a sub-rectangle sized by constraints; the usual way to place a popup

contains(Position), intersects(Rect)

Hit testing, for example for mouse clicks

rows(), columns(), positions()

Iterators over the rows, columns or every cell position

layout(&Layout), layout_vec(&Layout), try_layout(&Layout)

Split the rectangle with a Layout (rect.layout(&layout) is the same as layout.areas(rect))

All geometry types are u16, so positions computed by hand with plain arithmetic on the fields (x + width) can overflow in debug builds. Prefer clamp, intersection, contains and inner to hand-written arithmetic.

Layout, Constraint and Flex

Layout splits a Rect into several child Rect values along one direction. A layout is built from a direction and a list of Constraint values, and applied to an area.

Function Meaning

Layout::vertical(constraints)

Stack the segments top to bottom (Direction::Vertical, the default direction)

Layout::horizontal(constraints)

Place the segments left to right (Direction::Horizontal)

Layout::new(direction, constraints)

Same as the two above with an explicit Direction

.areas(area)

Returns a fixed-size array of Rect, destructured with let [a, b] = .... It panics if the number of constraints differs from the array length; try_areas returns an error instead

.split(area)

Returns an Rc<[Rect]> for a number of segments only known at run time

.spacers(area), .split_with_spacers(area)

Also return the spacer rectangles between and around the segments

.margin(n), .horizontal_margin(n), .vertical_margin(n)

Shrink the area before splitting

.flex(Flex)

How leftover space is distributed (see below)

.spacing(n)

Gap between segments; a positive number is a gap, a negative number makes the segments overlap

Constraints

Each segment is sized by one Constraint. When the constraints cannot all be satisfied (an 80-column terminal asked for 100 columns of fixed content), the solver follows a fixed priority.

Constraint Meaning Priority (highest first)

Min(u16)

At least this many cells

1

Max(u16)

At most this many cells

2

Length(u16)

Exactly this many cells

3

Percentage(u16)

A percentage of the whole area being divided; the result is rounded to whole cells

4

Ratio(u32, u32)

A fraction of the whole area, rounded

5

Fill(u16)

Shares the excess space with other Fill segments in proportion to their weights

6

Percentage and Ratio are computed against the whole space being divided, not the space left after the fixed segments, so [Length(10), Percentage(50)] of 100 columns gives 10 and 50, not 10 and 45. A third cannot be written as a percentage, so use Ratio(1, 3) or Fill(1) for it. Constraint::default() is Percentage(100), and a plain u16 converts into Length. Helpers from_lengths, from_percentages, from_ratios, from_mins, from_maxes and from_fills build a Vec of one kind of constraint from numbers. The Fill constraint is the one to prefer for the flexible region of a screen, because it expresses "the rest" explicitly and composes with other Fill weights ([Fill(1), Fill(2)] gives a one-third / two-thirds split).

Flex

Flex decides where the excess space goes when the constraints leave some over.

Variant Behaviour

Legacy

Stretches to fill the area; the excess goes to the last segment of the lowest priority (the behaviour before Flex existed)

Start, End, Center

Pack the segments at the start, at the end or in the middle of the area

SpaceBetween

Excess space between neighbouring segments, none at the edges

SpaceEvenly

Equal excess space in every gap, including before the first and after the last segment

SpaceAround

Each segment gets an equal share of the excess on both sides

The shelf screen

use ratatui::{
    layout::{Constraint, Flex, Layout, Margin, Rect},
    Frame,
};

fn split_screen(frame: &mut Frame) -> (Rect, Rect, Rect, Rect, Rect) {
    // Header of 3 rows, footer of 1 row, everything else for the body.
    let [header, body, footer] = Layout::vertical([
        Constraint::Length(3),
        Constraint::Fill(1),
        Constraint::Length(1),
    ])
    .areas(frame.area());

    // Body: 40 % list, the rest for the details pane, with a one-cell gap.
    let [list, details] = Layout::horizontal([Constraint::Percentage(40), Constraint::Fill(1)])
        .spacing(1)
        .areas(body);

    (header, list, details, footer, body.inner(Margin { vertical: 1, horizontal: 1 }))
}

fn button_row(area: Rect) -> [Rect; 2] {
    // Two fixed-width buttons centred in the row.
    Layout::horizontal([Constraint::Length(12), Constraint::Length(12)])
        .flex(Flex::Center)
        .spacing(2)
        .areas(area)
}
Constraints Length 3, Fill 1 and Length 1 turned into the header, body and footer rectangles of an 80 by 24 terminal, with the body split 40 percent and Fill into list and details

On an 80 x 24 terminal the vertical layout yields Rect { x: 0, y: 0, width: 80, height: 3 }, a 20-row body and a one-row footer; the horizontal split then gives the list roughly 40 percent of the width and the details pane the rest, minus the one-cell gap. The rectangles in the figure are rounded for readability, so tests should assert on rendered content rather than on exact widths computed by hand. Margin is written as a struct literal with vertical and horizontal fields, the form the official pages use.

Layout pitfalls

  • areas::<N> panics when the array length and the number of constraints differ. A length known only at run time needs split or try_areas.

  • A layout is recomputed every frame. With the default layout-cache feature the results of repeated identical splits are cached; Layout::init_cache sets the cache size before first use. Layouts are cheap, so build them in the draw function rather than storing them.

  • Areas may be empty when the terminal is small (Rect::is_empty). Code that indexes into a rectangle must check first. Test the application at a very small size.

  • When a widget does not appear, log its Rect first: a wrong or empty rectangle is the usual cause.

  • Percentages are rounded, so three Percentage(33) segments do not add up to the whole area. Use Fill or Ratio.

Text and styling primitives

Span, Line and Text

Ratatui models styled text in three layers, and every widget that shows text accepts anything convertible to them, so a plain &str or String works until styling is needed.

Type Holds Notes

Span

A run of characters sharing one Style

Span::raw(text), Span::styled(text, style); style replaces the style, patch_style merges; width() is the Unicode display width

Line

One line made of Span values, with its own style and optional alignment

Line::raw, Line::styled, spans(...), push_span, left_aligned(), centered(), right_aligned(), width()

Text

Several Line values (the lines field) with optional alignment

Text::raw("a\nb") splits on newlines; push_line, push_span, width() is the widest line and height() the line count

Masked wraps a string so that it is displayed as a mask character (for a password field); StyledGrapheme is the rendering-level unit and is not part of the Span / Line / Text hierarchy; the ToSpan, ToLine and ToText traits convert values to each layer. Alignment set on a Line overrides the alignment of the Text or Paragraph that contains it. A Span never wraps and never contains a newline: embedding \n in a span is a bug, build a Text.

use ratatui::{
    style::{Style, Stylize},
    text::{Line, Span, Text},
};

fn book_details<'a>(title: &'a str, author: &'a str, copies: u32) -> Text<'a> {
    let mut text = Text::default();
    text.push_line(Line::from(vec![
        Span::raw("Title:   "),
        Span::styled(title, Style::new().bold()),
    ]));
    text.push_line(Line::from(vec!["Author:  ".into(), author.cyan()]));
    text.push_line(Line::from(format!("Copies:  {copies}")).dark_gray());
    text
}

Style, Stylize, Color and Modifier

A Style is a set of optional changes: a foreground colour, a background colour, an underline colour and the added and removed Modifier flags. Styles are incremental: when several styles are applied to the same cell (the block, then the list, then the item, then the highlight), the cell ends up with the merge of all of them, with later styles winning on the properties they set. Style::default() changes nothing; Style::reset() resets every property at that point of the sequence.

use ratatui::style::{Color, Modifier, Style, Stylize};

let explicit = Style::new()
    .fg(Color::Black)
    .bg(Color::Green)
    .add_modifier(Modifier::BOLD | Modifier::ITALIC);

// Stylize shorthands build the same thing from a &str, Span, Line, widget or Style.
let shorthand = Style::new().black().on_green().bold().italic();
let label = "overdue".red().on_black().bold();
let warning = Style::new().yellow().remove_modifier(Modifier::DIM);

Stylize is implemented for every type that implements Styled: &str, String, Span, Line, Style and most widgets, so Paragraph::new("x").red() and Block::bordered().blue() work. Its shorthands are named after the colour (red(), dark_gray(), light_blue()), prefixed with on_ for backgrounds, and come in pairs for the modifiers (bold() and not_bold(), dim, italic, underlined, slow_blink, rapid_blink, reversed, hidden, crossed_out).

Color variant Meaning

Reset

The terminal’s default foreground or background

Black, Red, Green, Yellow, Blue, Magenta, Cyan

The seven standard ANSI colours (codes 30-36 and 40-46)

Gray

ANSI "white" (code 37); not a bright colour despite the name

DarkGray, LightRed, LightGreen, LightYellow, LightBlue, LightMagenta, LightCyan

The bright variants (codes 90-96 and 100-106)

White

ANSI bright white (code 97)

Indexed(u8)

An entry of the 256-colour palette

Rgb(u8, u8, u8)

24-bit true colour

Color also parses from strings ("light red", "#FF0000", "10" for an indexed colour), which is how colours read from a configuration file are turned into styles. The nine Modifier flags (BOLD, DIM, ITALIC, UNDERLINED, SLOW_BLINK, RAPID_BLINK, REVERSED, HIDDEN, CROSSED_OUT) are bit flags that combine with |.

Styling pitfalls and platform differences

  • Rgb is displayed correctly only on terminals with 24-bit colour. Per the official page the termwiz backend falls back to the default text colour when true colour is unsupported, while the crossterm and termion backends have no such fallback and the result may be unpredictable. Prefer the named colours and Indexed for portable output, and see Escape Sequences & Terminal Capabilities for how to detect colour support and for the NO_COLOR convention. The official documentation describes no automatic handling of NO_COLOR, so a TUI that must honour it has to implement that itself.

  • Style::underline_color needs the underline-color feature (on by default) and a non-standard escape sequence that only the crossterm backend implements.

  • Modifier::SLOW_BLINK, RAPID_BLINK, HIDDEN and CROSSED_OUT are accepted by every backend but displayed by only some terminals; do not rely on them to carry meaning. REVERSED is the most portable way to mark a selection and works on a terminal with no colour at all.

  • The names Gray and White follow the ANSI palette (standard and bright white), not the usual meaning of "gray"; the actual shades are chosen by the terminal theme. The same Color::Blue is a different blue in a Windows Terminal scheme, in macOS Terminal and in a Linux terminal, which is why contrast must be tested in more than one theme.

The Widget traits and Frame

A Widget is any type that can draw itself into a Buffer inside a Rect:

pub trait Widget {
    fn render(self, area: Rect, buf: &mut Buffer)
    where
        Self: Sized;
}

render takes self by value, but since Ratatui 0.26 the built-in widgets also implement Widget for a reference to themselves (&List, &Table and so on), so a widget built once can be stored and rendered on every frame. Strings (&str, String), Span, Line, Text and Option<W> implement Widget as well, which is why frame.render_widget("Hello", area) works. StatefulWidget adds a State associated type and a &mut state argument for widgets whose appearance depends on something that persists between frames (selection, scroll offset).

Frame method Purpose

area()

The Rect of the whole terminal

render_widget(widget, area)

Draw a Widget; later calls overwrite earlier ones on shared cells

render_stateful_widget(widget, area, &mut state)

Draw a StatefulWidget with its state

set_cursor_position(position)

Show the cursor after the frame renders (hidden if never called; used by text input)

buffer_mut()

Direct access to the frame’s Buffer as an escape hatch

count()

The frame sequence number, wrapping to zero after usize::MAX

The WidgetRef and StatefulWidgetRef traits (and FrameExt::render_widget_ref) render a widget through &self so that Box<dyn WidgetRef> trees are possible, but they are behind the unstable-widget-ref feature, come with no stability guarantees and may change or be removed in any release. The official pages state this explicitly. Applications that need heterogeneous widgets today normally use an enum of widget kinds or a closure per area instead.

Every built-in widget

ratatui-widgets 0.3.2 ships these widgets (the ratatui::widgets module re-exports all of them; the calendar and canvas widgets live in submodules).

Widget Shows State Main options

Block

Borders, titles and padding around other widgets

none

borders, border_type, title*, padding, style, shadow, merge_borders

Paragraph

Styled, optionally wrapped and scrolled text

none

wrap, scroll, alignment, block

List

Selectable list of items

ListState

highlight_style, highlight_symbol, scroll_padding, direction

Table

Rows and columns with header and footer

TableState

widths, header, footer, row_highlight_style, flex

Tabs

A horizontal tab bar

none (select)

select, divider, padding, highlight_style

Gauge

A block progress bar with a centred label

none

percent, ratio, label, use_unicode

LineGauge

A one-line progress bar

none

ratio, label, filled_symbol, filled_style

Sparkline

Compact data as a row of bars

none

data, max, direction, bar_set

BarChart

Values as vertical or horizontal bars, optionally grouped

none

data, bar_width, bar_gap, group_gap, max, direction

Chart

Datasets on a Cartesian grid

none

x_axis, y_axis, legend_position, Dataset::graph_type

Canvas

Freeform drawing with shapes

none

x_bounds, y_bounds, marker, paint

Scrollbar

A scrollbar for another widget

ScrollbarState

orientation, symbols and styles for thumb, track and arrows

calendar::Monthly

A month calendar (feature widget-calendar)

none

show_surrounding, show_weekdays_header, show_month_header, event styler

Clear

Resets an area so that something can be drawn over it

none

(a unit struct)

Fill

Fills an area with one repeated symbol and style

none

new, symbol, style

Shadow

A shadow drawn behind a Block

none

overlay, block, light_shade, medium_shade, dark_shade, symbol, style, offset

RatatuiLogo, RatatuiMascot

The Ratatui logo (tiny or small) and mascot, for about screens

none

RatatuiLogoSize, MascotEyeColor

Block

Block draws borders, titles and padding around other widgets and is the container almost every other widget accepts through its block(...) method. Block::bordered() is Block::new().borders(Borders::ALL); borders takes flags that combine with | (Borders::LEFT | Borders::RIGHT).

Method Effect

border_type(BorderType)

Plain (default), Rounded, Double, Thick, six dashed types (LightDoubleDashed, HeavyDoubleDashed, LightTripleDashed, HeavyTripleDashed, LightQuadrupleDashed, HeavyQuadrupleDashed), QuadrantInside and QuadrantOutside

border_style(style)

Style of the border cells only

title(...), title_top(...), title_bottom(...)

Add a title (anything convertible to Line); title uses the position set by title_position; Line alignment (left_aligned, centered, right_aligned) places it on the edge

title_alignment(...), title_position(TitlePosition), title_style(style)

Default alignment, default edge and a style applied to all titles

padding(Padding)

Space between the border and the content (Padding::uniform, horizontal, vertical, symmetric, proportional, new(left, right, top, bottom), ZERO)

style(style)

Base style of the whole block; border and title styles are layered on top

merge_borders(MergeStrategy)

How overlapping borders of neighbouring blocks join (Replace by default; Exact and Fuzzy merge the characters)

shadow(Shadow)

Draw a shadow behind the block

inner(area)

The content area left after borders, titles and padding; use it to place a widget inside a block you render separately

use ratatui::{
    style::Stylize,
    text::Line,
    widgets::{Block, BorderType, Padding},
};

let panel = Block::bordered()
    .border_type(BorderType::Rounded)
    .title(" Catalogue ")
    .title(Line::from(" 42 books ").right_aligned())
    .title_bottom(Line::from("j/k move  Enter open").centered())
    .padding(Padding::horizontal(1))
    .blue();

The border glyphs are Unicode box-drawing characters. They render correctly in Windows Terminal, in macOS Terminal and in modern Linux terminals; the legacy Windows console (conhost) with a raster font may show replacement glyphs for the rounded, dashed and quadrant sets, in which case BorderType::Plain is the safe choice. The ambiguous-width and double-width character problems explained on the escape-sequences page affect titles containing CJK or emoji text.

Paragraph

Paragraph::new takes anything convertible to Text. Options: block, style (the whole widget including the block), wrap(Wrap { trim }), scrolly, x, alignment and its shortcuts left_aligned, centered and right_aligned. Without wrap a long line is truncated at the area’s edge; with Wrap { trim: true } leading whitespace of wrapped lines is dropped, and with trim: false indentation is preserved. The scroll offset is given as (y, x), the reverse of the order used elsewhere in the crate.

Paragraph::line_count(width) and Paragraph::line_width() report the number of rendered lines and the narrowest width that avoids breaking a word, including the block. Both require the unstable-rendered-line-info feature, which the official page marks as having no stability guarantees; without them, scroll limits for wrapped text have to be estimated by hand.

use ratatui::{
    widgets::{Block, Paragraph, Wrap},
    Frame,
    layout::Rect,
};

fn draw_details(frame: &mut Frame, area: Rect, text: ratatui::text::Text<'_>, scroll_y: u16) {
    let details = Paragraph::new(text)
        .block(Block::bordered().title(" Details "))
        .wrap(Wrap { trim: true })
        .scroll((scroll_y, 0));
    frame.render_widget(details, area);
}

List and ListState

List::new takes any iterator of items convertible to ListItem (a &str, a Text or a Line works). It is a StatefulWidget whose state is a ListState.

List method Effect

block, style

Container and base style

highlight_style(style)

Style of the selected item, including its symbol; overrides item and content styles

highlight_symbol(line)

Text shown before the selected item (none by default)

repeat_highlight_symbol(bool)

Repeat symbol and style on every line of a multi-line selected item (default false)

highlight_spacing(HighlightSpacing)

Always reserves the symbol column permanently, WhenSelected only while something is selected, Never hides the symbol

direction(ListDirection)

Top-to-bottom (default) or bottom-to-top

scroll_padding(n)

Keep n items visible above and below the selection where possible

ListState holds the selected index and the scroll offset. Create it once and keep it in the application state, not in the draw function, otherwise the selection resets on every frame. Its API: selected(), select(Option<usize>) (which also resets the offset to 0), select_next(), select_previous(), select_first(), select_last(), scroll_down_by(u16), scroll_up_by(u16), offset(), offset_mut(), with_selected and with_offset. The "last" and "previous" operations store usize::MAX before the first render because the item count is not known yet; the list corrects the index while rendering, so the selection is always valid on screen, but the stored value may be out of range between a key press and the next draw. Read the selected index only after a draw, or clamp it against the length of your own collection.

Table and TableState

Table::new(rows, widths) takes rows (anything convertible to Row, built from cells convertible to Cell) and one Constraint per column. Without widths, columns are equal.

Table method Effect

header(Row), footer(Row)

A row pinned at the top or bottom

widths(...), column_spacing(n), flex(Flex)

Column sizing, gap and distribution of leftover width (Flex::Legacy gives it to the last column)

row_highlight_style, column_highlight_style, cell_highlight_style

Style of the selected row, column or cell (each overrides row and cell styles)

highlight_symbol(text), highlight_spacing(HighlightSpacing)

Symbol before the selected row and when its column is reserved (WhenSelected by default)

block, style

Container and base style

Row has height, top_margin, bottom_margin and style and Cell takes any Text, so a row can be taller than one line. TableState extends the list state with columns: selected(), selected_column(), selected_cell(), select, select_column, select_cell, select_next, select_next_column, select_previous, select_previous_column, select_first, select_last, scroll_down_by, scroll_up_by, scroll_left_by, scroll_right_by and offset(). The same "corrected during render" rule applies to the relative operations.

Tabs

Tabs::new(titles) takes titles convertible to Line. Unlike List, Tabs is not stateful: the selection is a builder argument, select(index) (or select(None)), so the application keeps the index and rebuilds the widget every frame. Options: block, style, highlight_style, divider (a pipe by default), padding(left, right), padding_left and padding_right (one space each by default).

A complete shelf screen

[package]
name = "shelf-tui"
version = "0.1.0"
edition = "2024"

[dependencies]
ratatui = "0.30"
use std::io;

use ratatui::{
    crossterm::event::{self, Event, KeyCode, KeyEventKind},
    layout::{Constraint, Layout, Rect},
    style::{Style, Stylize},
    text::{Line, Span},
    widgets::{
        Block, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph, Row, Table, Tabs,
        Wrap,
    },
    DefaultTerminal, Frame,
};

struct Book {
    title: &'static str,
    author: &'static str,
    on_loan: bool,
}

struct App {
    books: Vec<Book>,
    list_state: ListState,
    tab: usize,
    show_help: bool,
    exit: bool,
}

impl App {
    fn new() -> Self {
        let books = vec![
            Book { title: "Dune", author: "Frank Herbert", on_loan: true },
            Book { title: "Solaris", author: "Stanislaw Lem", on_loan: false },
            Book { title: "Neuromancer", author: "William Gibson", on_loan: false },
        ];
        App { books, list_state: ListState::default().with_selected(Some(0)), tab: 0, show_help: false, exit: false }
    }

    fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
        while !self.exit {
            terminal.draw(|frame| self.draw(frame))?;
            self.handle_events()?;
        }
        Ok(())
    }

    fn handle_events(&mut self) -> io::Result<()> {
        if let Event::Key(key) = event::read()? {
            if key.kind == KeyEventKind::Press {
                match key.code {
                    KeyCode::Char('q') => self.exit = true,
                    KeyCode::Tab => self.tab = (self.tab + 1) % 2,
                    KeyCode::Char('j') | KeyCode::Down => self.list_state.select_next(),
                    KeyCode::Char('k') | KeyCode::Up => self.list_state.select_previous(),
                    KeyCode::Char('?') => self.show_help = !self.show_help,
                    _ => {}
                }
            }
        }
        Ok(())
    }

    fn draw(&mut self, frame: &mut Frame) {
        let [header, body, footer] = Layout::vertical([
            Constraint::Length(3),
            Constraint::Fill(1),
            Constraint::Length(1),
        ])
        .areas(frame.area());

        let tabs = Tabs::new(["Catalogue", "Loans"])
            .select(self.tab)
            .block(Block::bordered().title(" shelf "))
            .highlight_style(Style::new().yellow().bold());
        frame.render_widget(tabs, header);

        if self.tab == 0 {
            self.draw_catalogue(frame, body);
        } else {
            self.draw_loans(frame, body);
        }

        frame.render_widget(
            Paragraph::new(" q quit   Tab switch   j/k move   ? help").dark_gray(),
            footer,
        );

        if self.show_help {
            let popup = frame.area().centered(Constraint::Percentage(60), Constraint::Length(5));
            frame.render_widget(Clear, popup);
            frame.render_widget(
                Paragraph::new("Browse the shelf with j and k.\nPress ? again to close.")
                    .block(Block::bordered().title(" Help "))
                    .wrap(Wrap { trim: true }),
                popup,
            );
        }
    }

    fn draw_catalogue(&mut self, frame: &mut Frame, area: Rect) {
        let [list_area, details_area] =
            Layout::horizontal([Constraint::Percentage(40), Constraint::Fill(1)]).areas(area);

        let items: Vec<ListItem> = self.books.iter().map(|b| ListItem::new(b.title)).collect();
        let list = List::new(items)
            .block(Block::bordered().title(" Books "))
            .highlight_style(Style::new().reversed())
            .highlight_symbol("> ")
            .highlight_spacing(HighlightSpacing::Always);
        frame.render_stateful_widget(list, list_area, &mut self.list_state);

        let selected = self.list_state.selected().and_then(|i| self.books.get(i));
        let text = match selected {
            Some(book) => vec![
                Line::from(vec![Span::raw("Title:  "), Span::styled(book.title, Style::new().bold())]),
                Line::from(vec![Span::raw("Author: "), book.author.cyan()]),
                Line::from(if book.on_loan { "On loan".red() } else { "Available".green() }),
            ],
            None => vec![Line::from("Nothing selected")],
        };
        frame.render_widget(
            Paragraph::new(text).block(Block::bordered().title(" Details ")),
            details_area,
        );
    }

    fn draw_loans(&self, frame: &mut Frame, area: Rect) {
        let header = Row::new(["Title", "Borrower", "Due"]).style(Style::new().bold()).bottom_margin(1);
        let rows = [Row::new(["Dune", "Ana", "2026-11-02"])];
        let widths = [Constraint::Fill(2), Constraint::Fill(1), Constraint::Length(10)];
        let table = Table::new(rows, widths)
            .header(header)
            .block(Block::bordered().title(" Loans "));
        frame.render_widget(table, area);
    }
}

fn main() -> io::Result<()> {
    ratatui::run(|terminal| App::new().run(terminal))
}

The loop is the minimal one from the getting-started page: draw, then block on one event. The popup is drawn last so that it covers everything under it, after Clear has reset its area. The screen runs unchanged on Windows, macOS and Linux because the crossterm backend hides the platform differences, but note the KeyEventKind::Press check: on Windows crossterm reports both key presses and releases, so without it every key would act twice (see Ratatui: Events, Async & Application Architecture).

Gauge and LineGauge

Gauge is a block-style progress bar. percent(u16) (0 to 100) and ratio(f64) (0.0 to 1.0) set the fill level and both panic when the value is out of range, so clamp computed values first. label(...) replaces the default percentage text, which is centred; gauge_style styles the bar and style styles the background and block; use_unicode(true) uses eight-step partial block characters for sub-cell precision.

LineGauge draws a thin line instead. ratio(f64) (also panicking outside 0 to 1), label(...) (always on the left), filled_symbol, unfilled_symbol, filled_style, unfilled_style, style and block. The older line_set (deprecated in 0.30.0) and gauge_style (deprecated in 0.27.0) should be replaced by the symbol and style methods.

use ratatui::{
    style::Stylize,
    symbols,
    widgets::{Block, Gauge, LineGauge},
};

let read = 130_u32;
let total = 312_u32;
let ratio = (f64::from(read) / f64::from(total)).clamp(0.0, 1.0);

let gauge = Gauge::default()
    .block(Block::bordered().title(" Reading progress "))
    .gauge_style(ratatui::style::Style::new().green().on_black())
    .use_unicode(true)
    .ratio(ratio)
    .label(format!("{read}/{total} pages"));

let line = LineGauge::default()
    .filled_symbol(symbols::line::THICK_HORIZONTAL)
    .filled_style(ratatui::style::Style::new().cyan())
    .label("Import")
    .ratio(0.4);

Sparkline and BarChart

Sparkline renders one bar per value in the width of its area. data(...) accepts u64 values, Option<u64> (a None is an absent value drawn with absent_value_symbol, by default the empty shade, and absent_value_style) or SparklineBar values with their own style. max(u64) fixes the scale (otherwise the largest value is used), direction(RenderDirection) renders left to right (default) or right to left, bar_set chooses symbols::bar::NINE_LEVELS (default), THREE_LEVELS or a custom set.

BarChart shows labelled bars, vertical by default. BarChart::new or vertical takes Bar values, horizontal draws horizontal bars, grouped takes BarGroup values and data adds one group at a time. A Bar is created with Bar::with_label(label, value) and may carry its own style and value style; a BarGroup is a labelled set of bars. Options: bar_width and bar_gap (default 1 each; labels never exceed the bar width), group_gap, bar_style, value_style, label_style, bar_set, max (the value that produces a full-height bar), direction, block and style.

use ratatui::{
    style::Style,
    widgets::{Bar, BarChart, BarGroup, Block, Sparkline},
};

let loans_per_day = [3_u64, 5, 2, 8, 6, 9, 4];
let spark = Sparkline::default()
    .block(Block::bordered().title(" Loans, last 7 days "))
    .data(&loans_per_day)
    .max(10)
    .style(Style::new().green());

let chart = BarChart::default()
    .block(Block::bordered().title(" Loans by genre "))
    .bar_width(5)
    .bar_gap(1)
    .group_gap(3)
    .bar_style(Style::new().yellow())
    .data(BarGroup::new([
        Bar::with_label("SF", 12),
        Bar::with_label("Poetry", 4),
    ]))
    .max(15);

Chart (with Dataset and Axis)

Chart::new(vec![datasets]) plots one or more Dataset values on two Axis values. A Dataset has name (datasets without a name are left out of the legend), data(&[(f64, f64)]) (the slice must outlive the chart; the Y axis runs from bottom to top as in mathematics), marker, graph_type and style. GraphType is Scatter (the default: only the points), Line (connect consecutive points in dataset order), Bar (a line from the X axis to each point) or Area (a line with the region filled down to fill_to_y, 0.0 by default). An Axis has title, bounds([min, max]), labels(...) (at least two to render; the official page warns that more than three labels is currently broken and middle labels may be misplaced), labels_alignment and style. Chart itself has block, style, legend_position(Option<LegendPosition>) (top right by default, None hides it) and hidden_legend_constraintswidth, height, which hide the legend when it takes more than 25 % of the chart by default.

use ratatui::{
    style::{Style, Stylize},
    symbols::Marker,
    widgets::{Axis, Block, Chart, Dataset, GraphType},
};

let loans = [(1.0, 4.0), (2.0, 7.0), (3.0, 5.0), (4.0, 9.0), (5.0, 8.0), (6.0, 12.0)];
let dataset = Dataset::default()
    .name("loans")
    .marker(Marker::Braille)
    .graph_type(GraphType::Line)
    .style(Style::new().cyan())
    .data(&loans);

let chart = Chart::new(vec![dataset])
    .block(Block::bordered().title(" Loans per month "))
    .x_axis(Axis::default().title("month").bounds([1.0, 6.0]).labels(["1", "6"]))
    .y_axis(Axis::default().title("loans").bounds([0.0, 12.0]).labels(["0", "12"]));

The Marker enum (used by Dataset::marker and Canvas::marker, and #[non_exhaustive], so a match needs a wildcard arm) selects how points are drawn: Dot, Block and Bar (one point per cell), Braille (2 x 4 dots per cell, the highest resolution and the canvas default), HalfBlock (twice the vertical resolution, with a foreground and background colour per cell), Quadrant (2 x 2), Sextant (2 x 3), Octant (2 x 4 without gaps between cells) and Custom(char). Braille needs a font that has the Unicode Braille Patterns block, and Sextant and Octant use the "Symbols for Legacy Computing" block, whose support is less widespread than Braille. Windows Terminal, macOS Terminal and the common Linux terminal emulators show Braille; the legacy console host with a raster font may not. Pick Dot or Block when the output must work everywhere.

Canvas

Canvas draws freeform shapes on a coordinate system independent of the cell grid. Build it with Canvas::default() and the options block, x_bounds([min, max]), y_bounds([min, max]) (changing the bounds zooms into part of the drawing), marker, background_color and paint(closure). The closure receives a Context with draw(&shape), layer() (starts a new layer so later shapes are drawn on top) and print(x, y, text) (text, unaffected by layers). The shapes in ratatui::widgets::canvas are Line, Rectangle, Circle, Points, Map (with MapResolution), FilledLine and Label; any type implementing the Shape trait can be drawn.

use ratatui::{
    style::Color,
    symbols::Marker,
    widgets::{
        canvas::{Canvas, Circle, Line, Rectangle},
        Block,
    },
};

let shelf = Canvas::default()
    .block(Block::bordered().title(" Shelf map "))
    .marker(Marker::Braille)
    .x_bounds([0.0, 100.0])
    .y_bounds([0.0, 20.0])
    .paint(|ctx| {
        ctx.draw(&Rectangle { x: 5.0, y: 2.0, width: 40.0, height: 12.0, color: Color::Yellow });
        ctx.draw(&Line { x1: 5.0, y1: 8.0, x2: 45.0, y2: 8.0, color: Color::Yellow });
        ctx.layer();
        ctx.draw(&Circle { x: 70.0, y: 10.0, radius: 6.0, color: Color::Green });
        ctx.print(48.0, 15.0, "Reading nook");
    });

The shapes are plain structs with public fields: Line has x1, y1, x2, y2 and color, Rectangle has x, y, width, height and color, and Circle has x, y, radius and color, all coordinates being f64 in the canvas bounds.

Scrollbar and ScrollbarState

Scrollbar is drawn beside another widget; it does not scroll anything itself. Create it with Scrollbar::new(ScrollbarOrientation::VerticalRight) (or HorizontalBottom, among others), customise it with begin_symbol, end_symbol, track_symbol, thumb_symbol (symbols; an Option for the arrows and track) and the matching *_style methods, symbols(Set), orientation_and_symbol and a single style for all parts. The state is a ScrollbarState: ScrollbarState::new(content_length), then position(n), content_length(n) and viewport_content_length(n), and the in-place prev(), next(), first(), last() and scroll(ScrollDirection). The scrollbar renders blank unless the content length is set before rendering. Render it over the same area as the scrolled widget, shrunk by a vertical margin so that it stays inside a border.

use ratatui::{
    layout::{Margin, Rect},
    widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState},
    Frame,
};

fn draw_scrollbar(frame: &mut Frame, area: Rect, total_lines: usize, scroll_y: usize) {
    let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
        .begin_symbol(Some("^"))
        .end_symbol(Some("v"));
    let mut state = ScrollbarState::new(total_lines).position(scroll_y);
    frame.render_stateful_widget(
        scrollbar,
        area.inner(Margin { vertical: 1, horizontal: 0 }),
        &mut state,
    );
}

The state is rebuilt from the application’s own scroll position on every frame in the example above, which is the simplest correct pattern: the application owns the position and the scrollbar only displays it.

Calendar

ratatui::widgets::calendar::Monthly shows the month that contains a given date and is available with the widget-calendar feature (on by default). Dates are time::Date values, so the application depends on the time crate. Monthly::new(display_date, events) takes any DateStyler; the bundled CalendarEventStore is a map from dates to styles (add(date, style), or today(style)). Options: show_surrounding(style) (fills the days of the neighbouring months), show_weekdays_header(style), show_month_header(style), default_style(style) and block. width() and height() report the size the calendar needs. The example below needs time = "0.3" in Cargo.toml next to ratatui.

use ratatui::{
    style::{Style, Stylize},
    widgets::{
        calendar::{CalendarEventStore, Monthly},
        Block,
    },
};
use time::{Date, Month};

let due = Date::from_calendar_date(2026, Month::November, 2).expect("valid date");
let mut events = CalendarEventStore::default();
events.add(due, Style::new().red().bold());

let calendar = Monthly::new(due, events)
    .show_month_header(Style::new().bold())
    .show_weekdays_header(Style::new().dark_gray())
    .show_surrounding(Style::new().dim())
    .block(Block::bordered().title(" Due dates "));

The official pages do not describe how the calendar chooses the first day of the week or localises month names; the display is fixed by the widget, so a localised calendar has to be drawn with a Table or a custom widget.

Clear, Fill and Shadow

Clear is a unit struct that resets every cell of its area so that something can be drawn over it. Render it immediately before a popup, otherwise the cells below show through wherever the popup leaves blanks. The official page points out that Clear cannot be used to clear the terminal on the first render, because Ratatui assumes the area is empty at that point; use Terminal::clear for that.

Fill::new(symbol) paints its whole area with one repeated symbol and style (symbol(...) and style(...) change them); it suits backgrounds, separators and hand-made borders. Shadow is a configurable shadow drawn behind a Block with Block::shadow. Its constructors choose the look: overlay() (style only), block() (full blocks), light_shade(), medium_shade(), dark_shade(), symbol("x") and custom(effect) for a type implementing CellEffect; style(...) sets the shadow’s style and offset(Offset) moves it (positive x to the right, positive y downwards).

use ratatui::{
    layout::Offset,
    style::Stylize,
    widgets::{Block, Clear, Shadow},
};

// Reset the popup area first, then draw a block with a shadow on top of it.
// frame.render_widget(Clear, popup);
let dialog = Block::bordered()
    .title(" Return book? ")
    .shadow(Shadow::dark_shade().black().on_white().offset(Offset::new(2, 1)));
// frame.render_widget(dialog, popup);

The official pages do not state in which release Fill, Shadow and Block::shadow appeared; treat them as present in 0.30.2 and check the changelog before depending on them from an older version.

RatatuiLogo and RatatuiMascot

RatatuiLogo draws the Ratatui logo on two lines in one of two sizes (RatatuiLogo::tiny(), 2 x 15 cells, the default, and RatatuiLogo::small(), 2 x 27 cells, or new(RatatuiLogoSize)); the documentation suggests it for an "about" or help screen of an application built on Ratatui. RatatuiMascot renders the project’s mascot, with the eye state chosen through MascotEyeColor. Neither is useful for anything but decoration.

Stateful widgets

List, Table and Scrollbar are StatefulWidget implementations. Three rules cover almost every bug with them.

  1. Keep the state in the application, not in draw. A fresh ListState::default() every frame means the selection and scroll offset reset every frame.

  2. Render with render_stateful_widget and pass &mut state. The widget updates the offset while rendering so that the selection is visible; that is why the draw function takes &mut self (or the state in a RefCell).

  3. Treat the state as valid only after a draw. The relative operations store sentinel values that are corrected during rendering.

Tabs, Gauge and Sparkline look stateful but are not: the selected tab, ratio or data is an argument rebuilt on every frame. The same immediate-mode rule applies to your own widgets: build them from the current application state inside draw.

Writing a custom widget

A custom widget implements Widget (or StatefulWidget) for itself, and by convention for a reference to itself so it can be rendered repeatedly. The Buffer offers set_string(x, y, text, style) and set_style(area, style), and other widgets can be rendered into a sub-area by calling their render directly.

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Style, Stylize},
    text::Line,
    widgets::{StatefulWidget, Widget},
};

/// Shows a star rating such as "***--" for a book.
struct Rating {
    stars: u8,
    max: u8,
}

impl Widget for &Rating {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let filled = "*".repeat(self.stars as usize);
        let empty = "-".repeat(self.max.saturating_sub(self.stars) as usize);
        buf.set_string(area.x, area.y, filled, Style::new().yellow());
        buf.set_string(area.x + self.stars as u16, area.y, empty, Style::new().dark_gray());
    }
}

impl Widget for Rating {
    fn render(self, area: Rect, buf: &mut Buffer) {
        (&self).render(area, buf);
    }
}

/// A pager that remembers which page it shows between frames.
struct Pager {
    total: usize,
}

#[derive(Default)]
struct PagerState {
    page: usize,
}

impl StatefulWidget for Pager {
    type State = PagerState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut PagerState) {
        state.page = state.page.min(self.total.saturating_sub(1));
        Line::from(format!("page {}/{}", state.page + 1, self.total)).render(area, buf);
    }
}

Notes for custom widgets:

  • Always respect area: write only inside it, and handle an empty or one-cell area. The cells of a neighbouring widget inside the buffer are not protected from a widget that writes outside its rectangle.

  • The widget does not know about the terminal or the backend; it only writes cells. That is what makes it testable with TestBackend (see Ratatui: Testing & Ecosystem).

  • Account for display width: use Line::width() or Span::width() rather than str::len, because wide characters occupy two cells and combining characters none.

  • Reuse built-in widgets inside your own: render a Block into area, take block.inner(area) and render the content there. Composition is what the ecosystem of third-party widgets does as well.

  • The widget documentation describes no focus management, no mouse hit-testing beyond Rect::contains and no retained widget tree. Those belong to the application architecture or to a framework layer such as tui-realm.

Pitfalls

  • Drawing in draw has no side effects other than the buffer; reading input, calling the network or sleeping inside it freezes the screen.

  • Printing to stdout with println! while the alternate screen is active corrupts the display. Log to a file or to stderr redirected elsewhere.

  • The terminal must be restored on exit and on panic; ratatui::run and ratatui::init / ratatui::restore handle the normal path and the application architecture page explains the panic hook.

  • Data shown in a Paragraph, List or Table may come from files, network or other users. Control characters in that data (escape sequences, carriage returns) must be stripped before display, because they can interfere with the terminal even though Ratatui writes through a cell buffer; see the injection warning on Escape Sequences & Terminal Capabilities.

  • Set a minimum size: render a "terminal too small" message with a Paragraph when frame.area() is smaller than the layout needs, instead of letting constraints collapse to zero-width areas.

  • Prefer a screen that is a pure function of the application state; it is the only form that can be tested with a buffer snapshot.

  • Keep Block titles short: titles longer than the border are truncated, not wrapped.

  • An accessibility note: a full-screen interface is read cell by cell by screen readers, and colour alone must not carry meaning. The rule that a TUI is a view over a command line interface is explained on TUIs vs. CLIs.

What the official documentation does not provide: a widget gallery that shows the rendered result of every widget in this page, guidance on the performance of very large tables, and rendered output for the new Fill, Shadow and logo widgets. The descriptions above therefore follow the API documentation, and the visual result should be checked in the terminals you support.