Skip to content

Table

Table is a generic, data-driven table component available with the table feature. You describe the columns and give it a list of rows plus a render_row closure; the component owns width resolution, CJK/emoji-aware cell wrapping, cell-grid borders, selection, a footer row, and layout height. It stays business-neutral: the row type T is yours, and the component never assumes what a row means.

table demo

This recording is generated by docs/tapes/table.tape. It selects a row by default (the gutter and row highlight), moves the column highlight with l/h, switches density with c, reveals the footer totals with PageDown, and toggles the empty state with e.

cargo run --example table

Press j/k to move the selected row, h/l to move the selected column, PageUp/PageDown or the mouse wheel to scroll, c to toggle compact density, e to toggle the empty state, and q to quit.

Table is behind the table feature:

ratatui-kit = { version = "...", features = ["table"] }

Examples use the full feature, so they can be run directly. The library has an intentionally empty default feature set; a bare cargo check will not compile the table component.

Table is a from-scratch renderer, not a wrapper around ratatui::widgets::Table. It exists to do three things the native widget cannot:

  • Cell grid linesTableBorderMode::Grid draws box-drawing borders between every cell and row (native tables only have an outer Block). This is what makes it suitable for markdown-style tables.
  • Automatic cell wrapping — long cell text wraps to fit the column width with correct CJK/emoji visual widths (native tables truncate, or require you to pre-wrap and set a row height).
  • Responsive columns — a column can disappear below a width threshold with min_table_width.

If you want the native windowed-scrolling table instead (only visible rows rendered, 2D cell selection, a footer), it is already reachable through the stateful(...) adapter, since ratatui::widgets::Table is a StatefulWidget. Reach for Table when you want borders, wrapping, and framework-integrated selection.

A column is a header, a width Constraint, an alignment, and an optional responsive threshold:

TableColumn::new("Service / 服务", Constraint::Length(22))
TableColumn::new("Latency", Constraint::Length(9)).alignment(TableCellAlignment::Right)
TableColumn::new("Note / 备注", Constraint::Fill(1)).min_table_width(100)

Widths accept Length / Min / Max / Percentage / Ratio / Fill; the component resolves them against the available width (after borders, padding, and the selection gutter) and scales down proportionally on overflow. min_table_width(n) hides the column entirely while the table is narrower than n, which keeps a wide table readable when the terminal shrinks.

Rows are your own data. render_row turns one row (and whether it is selected) into a Vec<TableCell>, one cell per column:

let render_row: RenderTableRow<Deployment> = Arc::new(|deployment, _selected| {
    vec![
        TableCell::new(deployment.service).style(Style::new().fg(Color::White).bold()),
        TableCell::new(deployment.owner),
        TableCell::new(format!("{}ms", deployment.latency))
            .alignment(TableCellAlignment::Right),
    ]
});

Table<Deployment>(
    columns: wide_columns(),
    rows: deployments,
    render_row: Some(render_row),
)

A TableCell is a Line plus an optional per-cell style and alignment (the alignment falls back to the column’s). Cells beyond the column count are ignored; missing cells render empty.

Table<Row>(
    border_mode: TableBorderMode::Grid,     // None | Outer | Grid
    wrap_mode: TableWrapMode::Wrap,         // Wrap | Truncate
    cell_padding: 1u16,
    column_spacing: 1u16,                    // only used by border_mode = None
    header_separator: true,
    row_separator: true,
    border_style: Style::new().fg(Color::DarkGray),
    horizontal_line_style: Style::new().fg(Color::DarkGray),
)
  • Grid draws a full cell grid; Outer draws only the outer box; None draws no borders and uses column_spacing between columns.
  • Wrap breaks long lines at whitespace (falling back to a hard break), measuring CJK/emoji as width 2; Truncate clips to one line.
  • Row separators and the header separator only take a visible line in Grid mode.

Pass a footer row of cells (one per column) for totals or a summary line. It is aligned to the same columns and drawn after the body, with an optional separator:

Table<Deployment>(
    footer: footer_cells(&rows),           // Vec<TableCell>, empty = no footer
    footer_style: Style::new().fg(Color::Cyan).bold(),
    footer_separator: true,
)

Table tracks a selected row (and optionally a selected column) in TableState.

Table<Row>(
    active: true,                           // handle j/k/Home/End/Enter
    default_index: Some(0),
    highlight_style: Style::new().bg(Color::Rgb(45, 55, 95)),   // selected row
    highlight_symbol: Some("▶ "),
    highlight_spacing: HighlightSpacing::WhenSelected,          // Always | WhenSelected | Never
    column_highlight_style: Style::new().bg(Color::Rgb(70, 55, 25)),  // selected column
    cell_highlight_style: Style::new().add_modifier(Modifier::REVERSED),  // intersection
    column_navigation: true,                // Left/Right move the selected column
    on_select: move |row| { /* Enter */ },
)
  • highlight_spacing decides whether the selection symbol reserves a leading gutter. With the default WhenSelected, the gutter appears while a row is selected, so the symbol never overwrites the first column’s content (the native HighlightSpacing behavior). Always reserves it unconditionally; Never never draws the symbol.
  • column_highlight_style tints the whole selected column (header, body, and footer); cell_highlight_style patches on top at the row/column intersection. Both are empty by default, so they only show once a column is selected.
  • Built-in keys (when active): j/k / Down/Up move the row, Home/End jump, Enter fires on_select. Setting column_navigation: true adds h/l / Left/Right to move the selected column.

By default Table owns its TableState. Pass external state when the page needs to read the current selection, drive it from its own keys, or keep it stable across re-mounts. The example drives selection from the page so the surrounding ScrollView keeps owning PageUp/PageDown and the wheel:

let table_state = hooks.use_state(TableState::default);

// In the page's own handler:
match key.code {
    KeyCode::Char('j') => { table_state.write().next(row_count); }
    KeyCode::Char('l') => { table_state.write().next_column(COLUMN_COUNT); }
    _ => {}
}

Table<Deployment>(
    state: table_state,
    active: false,          // the page drives selection instead
    // ...
)

TableState exposes selected() / select() / next() / previous() / select_first() / select_last() for rows, and selected_column() / select_column() / next_column() / previous_column() for columns. The selected column is an index into the full column list, so it stays stable even when responsive hiding changes which columns are visible.

Table accepts width, height, margin, and offset. When height is left at the default, it estimates the rendered height (including wrapping, grid lines, and the footer) so the table takes exactly the space it needs. For a table taller than its viewport, wrap it in a ScrollView — the example does this and lets the ScrollView clip the overflow while PageUp/PageDown scroll it. For plain long lists prefer VirtualList; for ordinary single/multi select see Select and MultiSelect.