Data Table

A comprehensive data table component designed for handling large datasets with high performance. Features virtual scrolling, column configuration, sorting, filtering, row/column/cell selection, and custom cell rendering. Perfect for displaying tabular data with thousands of rows while maintaining smooth performance.

Install

#Key Features

  • Multiple Selection Modes: Row, column, and individual cell selection
  • Virtual Scrolling: Handle thousands of rows with smooth performance
  • Column Management: Resizable, movable, and fixed columns
  • Sorting: Built-in column sorting support
  • Keyboard Navigation: Full keyboard support for all selection modes
  • Custom Cell Rendering: Render any content in table cells
  • Context Menus: Right-click support for rows and cells
  • Infinite Loading: Load more data as user scrolls
  • Events: Comprehensive event system for user interactions

#Import

use gpui_kit::component::table::{
    DataTable, TableState, TableDelegate,
    Column, ColumnSort, ColumnFixed,
    TableEvent
};
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { ColumnSort, DataTableColumn, DataTableCell, DataTableRow, TableSort, DataTable } from "data-table.slint";
use iced::widget::{column, container, mouse_area, row, svg, text};
use iced::{Alignment, Element, Length, Task, Theme};

#Usage

#Invoices

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The component's own API, written out once: typed columns, typed rows, and
// the two handlers. The app keeps the sort and the selection, so the header
// arrow and the selected row are the app's state rather than the table's.
let basic_columns = vec![
    Column::new("invoice", "Invoice").width(100.0).sortable(),
    Column::new("status", "Status").width(100.0).sortable(),
    Column::new("amount", "Amount").align(Align::End).sortable(),
];
let basic_rows = rows_for(INVOICES, app.sorts[section::INVOICES]);
let mut invoiced = DataTable::new()
    .columns(basic_columns)
    .rows(basic_rows)
    .selected(app.selected[section::INVOICES])
    .height(Length::Fixed(208.0))
    .on_sort(|column, direction| Message::Sort {
        table: section::INVOICES,
        column,
        direction,
    })
    .on_select(|index| Message::Select {
        table: section::INVOICES,
        index,
    });
if let Some((column, direction)) = app.sorts[section::INVOICES] {
    invoiced = invoiced.sort(column, direction);
}
let invoices = invoiced;

To create a table, you need to implement the TableDelegate trait and provide column definitions, and use TableState to manage the table state.

use std::ops::Range;
use gpui_kit::{App, Context, Window, IntoElement};
use gpui_kit::component::table::{DataTable, TableDelegate, Column, ColumnSort};

struct MyData {
    id: usize,
    name: String,
    age: u32,
    email: String,
}

struct MyTableDelegate {
    data: Vec<MyData>,
    columns: Vec<Column>,
}

impl MyTableDelegate {
    fn new() -> Self {
        Self {
            data: vec![
                MyData { id: 1, name: "John".to_string(), age: 30, email: "john@example.com".to_string() },
                MyData { id: 2, name: "Jane".to_string(), age: 25, email: "jane@example.com".to_string() },
            ],
            columns: vec![
                Column::new("id", "ID").width(60.),
                Column::new("name", "Name").width(150.).sortable(),
                Column::new("age", "Age").width(80.).sortable(),
                Column::new("email", "Email").width(200.),
            ],
        }
    }
}

impl TableDelegate for MyTableDelegate {
    fn columns_count(&self, _: &App) -> usize {
        self.columns.len()
    }

    fn rows_count(&self, _: &App) -> usize {
        self.data.len()
    }

    fn column(&self, col_ix: usize, _: &App) -> Column {
        self.columns[col_ix].clone()
    }

    fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, _: &mut Context<TableState<Self>>) -> impl IntoElement {
        let row = &self.data[row_ix];
        let col = &self.columns[col_ix];

        match col.key.as_ref() {
            "id" => row.id.to_string(),
            "name" => row.name.clone(),
            "age" => row.age.to_string(),
            "email" => row.email.clone(),
            _ => "".to_string(),
        }
    }
}

// Create the table
let delegate = MyTableDelegate::new();
let state = cx.new(|cx| TableState::new(delegate, window, cx));
DataTable {
    height: 240px;
    columns: [
        { key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
    ];
    rows: [
        root.invoice("INV001", "Paid", Status.success, "$250"),
        root.invoice("INV002", "Pending", Status.warning, "$150"),
        root.invoice("INV003", "Unpaid", Status.danger, "$350"),
        root.invoice("INV004", "Paid", Status.success, "$90"),
        root.invoice("INV005", "Pending", Status.warning, "$1,200"),
    ];
}

#Column Configuration

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// A column's width, alignment and sortability are its own: a fixed ID, a
// sortable name, an end-aligned age, and an email taking what is left. A
// header without `sortable` draws no indicator and no hover.
let configuration = body_box(
    data_table(
        app,
        section::CONFIGURATION,
        vec![
            Column::new("id", "ID").width(64.0),
            Column::new("name", "Name").width(140.0).sortable(),
            Column::new("age", "Age").width(72.0).align(Align::End).sortable(),
            Column::new("email", "Email"),
        ],
        PEOPLE,
    )
    .height(Length::Fixed(150.0)),
);

Columns provide extensive configuration options:

// Basic column
Column::new("id", "ID")

// Sortable column
Column::new("name", "Name")
    .sortable()
    .width(150.)

// Right-aligned column
Column::new("price", "Price")
    .text_right()
    .sortable()

// Fixed column (pinned to left)
Column::new("actions", "Actions")
    .fixed(ColumnFixed::Left)
    .resizable(false)
    .movable(false)

// Column with custom padding
Column::new("description", "Description")
    .width(200.)
    .paddings(px(8.))

// Non-resizable column
Column::new("status", "Status")
    .width(100.)
    .resizable(false)

// Custom sort orders
Column::new("created", "Created")
    .ascending() // Default ascending
// or
Column::new("modified", "Modified")
    .descending() // Default descending
DataTable {
    height: 180px;
    columns: [
        { key: "id", title: "ID", width: 64px, align: TextHorizontalAlignment.left, sortable: false, resizable: false },
        { key: "name", title: "Name", width: 140px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
        { key: "age", title: "Age", width: 72px, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
        { key: "email", title: "Email", width: 0, align: TextHorizontalAlignment.left, sortable: false, resizable: true },
    ];
    rows: [
        {
            id: "1",
            cells: [
                root.cell("1", Status.default),
                root.cell("Ada Lovelace", Status.default),
                root.cell("36", Status.default),
                root.cell("ada@uni.dev", Status.default)
            ]
        },
        {
            id: "2",
            cells: [
                root.cell("2", Status.default),
                root.cell("Alan Turing", Status.default),
                root.cell("41", Status.default),
                root.cell("alan@uni.dev", Status.default)
            ]
        },
        {
            id: "3",
            cells: [
                root.cell("3", Status.default),
                root.cell("Grace Hopper", Status.default),
                root.cell("85", Status.default),
                root.cell("grace@uni.dev", Status.default)
            ]
        },
    ];
}

#Virtual Scrolling for Large Datasets

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Slint's body instantiates only the visible rows, so thousands stay cheap.
// iced's `scrollable` lays out and draws every row it is given, on every
// pass; this bounded body scrolls all sixteen, and the module names the gap
// rather than pretending a windowing layer exists.
let virtual_scrolling = body_box(
    data_table(app, section::VIRTUAL, invoice_columns(), VIRTUAL).height(Length::Fixed(190.0)),
);

The table automatically handles virtual scrolling for optimal performance:

struct LargeDataDelegate {
    data: Vec<Record>, // Could be 10,000+ items
    columns: Vec<Column>,
}

impl TableDelegate for LargeDataDelegate {
    fn rows_count(&self, _: &App) -> usize {
        self.data.len() // No performance impact regardless of size
    }

    // Only visible rows are rendered
    fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, _: &mut Context<TableState<Self>>) -> impl IntoElement {
        // This is only called for visible rows
        // Efficiently render cell content
        let row = &self.data[row_ix];
        format_cell_data(row, col_ix)
    }

    // Track visible range for optimizations
    fn visible_rows_changed(&mut self, visible_range: Range<usize>, _: &mut Window, _: &mut Context<TableState<Self>>) {
        // Only update data for visible rows if needed
        // This is called when user scrolls
    }
}
DataTable {
    height: 220px;
    columns: [
        { key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
    ];
    rows: [
        root.invoice("INV001", "Paid", Status.success, "$250"),
        root.invoice("INV002", "Pending", Status.warning, "$150"),
        root.invoice("INV003", "Unpaid", Status.danger, "$350"),
        root.invoice("INV004", "Paid", Status.success, "$90"),
        root.invoice("INV005", "Pending", Status.warning, "$1,200"),
        root.invoice("INV006", "Paid", Status.success, "$480"),
        root.invoice("INV007", "Unpaid", Status.danger, "$75"),
        root.invoice("INV008", "Paid", Status.success, "$2,040"),
        root.invoice("INV009", "Pending", Status.warning, "$610"),
        root.invoice("INV010", "Paid", Status.success, "$128"),
        root.invoice("INV011", "Unpaid", Status.danger, "$990"),
        root.invoice("INV012", "Pending", Status.warning, "$340"),
        root.invoice("INV013", "Paid", Status.success, "$1,760"),
        root.invoice("INV014", "Unpaid", Status.danger, "$55"),
        root.invoice("INV015", "Paid", Status.success, "$870"),
        root.invoice("INV016", "Pending", Status.warning, "$430"),
    ];
}

#Sorting Implementation

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Sorting stays with the application, which is the Slint `sort-changed`
// branch: the header reports the direction the cycle lands on and the caller
// reorders. `rows_for` is that order function, reading numbers by value so
// `$1,200` sorts above `$90`.
let sorting = body_box(
    data_table(
        app,
        section::SORTING,
        vec![
            Column::new("name", "Name").width(160.0).sortable(),
            Column::new("age", "Age").width(72.0).align(Align::End).sortable(),
            Column::new("email", "Email").sortable(),
        ],
        NAMES,
    )
    .height(Length::Fixed(170.0)),
);

Implement sorting in your delegate:

impl TableDelegate for MyTableDelegate {
    fn perform_sort(&mut self, col_ix: usize, sort: ColumnSort, _: &mut Window, _: &mut Context<TableState<Self>>) {
        let col = &self.columns[col_ix];

        match col.key.as_ref() {
            "name" => {
                match sort {
                    ColumnSort::Ascending => self.data.sort_by(|a, b| a.name.cmp(&b.name)),
                    ColumnSort::Descending => self.data.sort_by(|a, b| b.name.cmp(&a.name)),
                    ColumnSort::Default => {
                        // Reset to original order or default sort
                        self.data.sort_by(|a, b| a.id.cmp(&b.id));
                    }
                }
            }
            "age" => {
                match sort {
                    ColumnSort::Ascending => self.data.sort_by(|a, b| a.age.cmp(&b.age)),
                    ColumnSort::Descending => self.data.sort_by(|a, b| b.age.cmp(&a.age)),
                    ColumnSort::Default => self.data.sort_by(|a, b| a.id.cmp(&b.id)),
                }
            }
            _ => {}
        }
    }
}
DataTable {
    height: 200px;
    columns: [
        { key: "name", title: "Name", width: 160px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "age", title: "Age", width: 72px, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
        { key: "email", title: "Email", width: 0, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
    ];
    rows: [
        {
            id: "cara",
            cells: [
                root.cell("Cara", Status.default),
                root.cell("41", Status.default),
                root.cell("cara@uni.dev", Status.default)
            ]
        },
        {
            id: "bob",
            cells: [
                root.cell("Bob", Status.default),
                root.cell("22", Status.default),
                root.cell("bob@uni.dev", Status.default)
            ]
        },
        {
            id: "ada",
            cells: [
                root.cell("Ada", Status.default),
                root.cell("36", Status.default),
                root.cell("ada@uni.dev", Status.default)
            ]
        },
        {
            id: "dan",
            cells: [
                root.cell("Dan", Status.default),
                root.cell("27", Status.default),
                root.cell("dan@uni.dev", Status.default)
            ]
        },
    ];
}

#ContextMenu

impl TableDelegate for MyTableDelegate {
    // Context menu for right-click
    fn context_menu(&mut self, row_ix: usize, menu: PopupMenu, _: &mut Window, _: &mut Context<TableState<Self>>) -> PopupMenu {
        let row = &self.data[row_ix];
        menu.menu(format!("Edit {}", row.name), Box::new(EditRowAction(row_ix)))
            .menu("Delete", Box::new(DeleteRowAction(row_ix)))
            .separator()
            .menu("Duplicate", Box::new(DuplicateRowAction(row_ix)))
    }
}
SlintNo Slint version of this example yet.

#Cell Rendering

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// A cell's status colours its text — `Theme.status_color` in the Slint
// component — so a gain reads green and a loss red without a badge in the
// row.
let cells = body_box(
    data_table(
        app,
        section::CELLS,
        vec![
            Column::new("symbol", "Symbol").width(100.0).sortable(),
            Column::new("side", "Side").width(80.0),
            Column::new("pnl", "P&L").align(Align::End).sortable(),
        ],
        TRADES,
    )
    .height(Length::Fixed(150.0)),
);

Create rich cell content with custom rendering:

impl TableDelegate for MyTableDelegate {
    fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context<TableState<Self>>) -> impl IntoElement {
        let row = &self.data[row_ix];
        let col = &self.columns[col_ix];

        match col.key.as_ref() {
            "status" => {
                // Custom status badge
                let (color, text) = match row.status {
                    Status::Active => (cx.theme().green, "Active"),
                    Status::Inactive => (cx.theme().red, "Inactive"),
                    Status::Pending => (cx.theme().yellow, "Pending"),
                };

                div()
                    .px_2()
                    .py_1()
                    .rounded(px(4.))
                    .bg(color.opacity(0.1))
                    .text_color(color)
                    .child(text)
            }
            "progress" => {
                // Progress bar
                div()
                    .w_full()
                    .h(px(8.))
                    .bg(cx.theme().muted)
                    .rounded(px(4.))
                    .child(
                        div()
                            .h_full()
                            .w(percentage(row.progress))
                            .bg(cx.theme().primary)
                            .rounded(px(4.))
                    )
            }
            "actions" => {
                // Action buttons
                h_flex()
                    .gap_1()
                    .child(Button::new(format!("edit-{}", row_ix)).text().icon(IconName::Edit))
                    .child(Button::new(format!("delete-{}", row_ix)).text().icon(IconName::Trash))
            }
            "avatar" => {
                // User avatar with image
                h_flex()
                    .items_center()
                    .gap_2()
                    .child(
                        div()
                            .w(px(32.))
                            .h(px(32.))
                            .rounded_full()
                            .bg(cx.theme().accent)
                            .flex()
                            .items_center()
                            .justify_center()
                            .child(row.name.chars().next().unwrap_or('?').to_string())
                    )
                    .child(row.name.clone())
            }
            _ => row.get_field_value(col.key.as_ref()).into_any_element(),
        }
    }
}
DataTable {
    height: 180px;
    columns: [
        { key: "symbol", title: "Symbol", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "side", title: "Side", width: 80px, align: TextHorizontalAlignment.left, sortable: false, resizable: false },
        { key: "pnl", title: "P&L", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
    ];
    rows: [
        {
            id: "aapl",
            cells: [
                root.cell("AAPL", Status.default),
                root.cell("Long", Status.info),
                root.cell("+2.40%", Status.success)
            ]
        },
        {
            id: "nvda",
            cells: [
                root.cell("NVDA", Status.default),
                root.cell("Short", Status.warning),
                root.cell("-1.10%", Status.danger)
            ]
        },
        {
            id: "tsla",
            cells: [
                root.cell("TSLA", Status.default),
                root.cell("Long", Status.info),
                root.cell("+3.18%", Status.success)
            ]
        },
        {
            id: "amzn",
            cells: [
                root.cell("AMZN", Status.default),
                root.cell("Short", Status.warning),
                root.cell("-0.42%", Status.danger)
            ]
        },
    ];
}

#Selection Modes

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The table is selectable whenever the caller hands it an `on_select`; the
// selected index is drawn on the selection token, which is the Slint
// `Theme.selection` at 0.15 in the light theme and 0.2 in the dark one.
let selection_line_below = selection_line(
    app,
    section::SELECTION,
    "Selected row ",
    "No row selected",
    false,
);
let selection = body_box(column![
    data_table(app, section::SELECTION, invoice_columns(), INVOICES).height(Length::Fixed(150.0)),
    selection_line_below,
]
.spacing(theme::SPACING_SM));

The table supports three distinct selection modes:

// Row selection mode (default)
let state = cx.new(|cx| {
    TableState::new(delegate, window, cx)
        .row_selectable(true)  // Enable row selection
        .col_selectable(false)
        .cell_selectable(false)
});

// Column selection mode
let state = cx.new(|cx| {
    TableState::new(delegate, window, cx)
        .row_selectable(false)
        .col_selectable(true)  // Enable column selection
        .cell_selectable(false)
});

// Cell selection mode
let state = cx.new(|cx| {
    TableState::new(delegate, window, cx)
        .row_selectable(true)   // Keep row selection for row selector column
        .col_selectable(false)
        .cell_selectable(true)  // Enable cell selection
});
VerticalLayout {
    spacing: 8px;
    selectable := DataTable {
        height: 180px;
        row-selectable: true;
        columns: [
            { key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
            { key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
            { key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
        ];
        rows: [
            root.invoice("INV001", "Paid", Status.success, "$250"),
            root.invoice("INV002", "Pending", Status.warning, "$150"),
            root.invoice("INV003", "Unpaid", Status.danger, "$350"),
            root.invoice("INV004", "Paid", Status.success, "$90"),
        ];
    }
    Text {
        text: selectable.selected-row < 0 ? "No row selected" : "Selected row \{selectable.selected-row + 1}";
        color: Theme.muted-foreground;
        font-size: Theme.font-size-sm;
    }
}

#Reading and Writing the Selection

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The selection is one read and one write on the caller's side: a button can
// set it and clear it without the table knowing, which is the Slint
// `selected-row` property.
let selection_controls = row![
    Button::new("Select row 3")
        .outline()
        .on_press(Message::Select {
            table: section::SELECTION_API,
            index: 2,
        }),
    Button::new("Clear")
        .outline()
        .on_press(Message::ClearSelection(section::SELECTION_API)),
]
.spacing(theme::SPACING_SM);
let selection_api_line =
    selection_line(app, section::SELECTION_API, "selected_row: ", "selected_row: none", true);
let selection_api = body_box(column![
    selection_controls,
    data_table(app, section::SELECTION_API, invoice_columns(), INVOICES)
        .height(Length::Fixed(150.0)),
    selection_api_line,
]
.spacing(theme::SPACING_SM));

A table holds one selection at a time: nothing, a row, a column, or a cell. selection() returns it as a single TableSelection value, and set_selection() writes one back, so persisting and restoring the selection is one read and one write:

use gpui_component::table::TableSelection;

match state.read(cx).selection() {
    TableSelection::None => {}
    TableSelection::Row(row_ix) => println!("Row {row_ix}"),
    TableSelection::Column(col_ix) => println!("Column {col_ix}"),
    TableSelection::Cell(row_ix, col_ix) => println!("Cell ({row_ix}, {col_ix})"),
}

state.update(cx, |state, cx| {
    // Scrolls and emits exactly as `set_selected_cell(5, 3, cx)` would.
    state.set_selection(TableSelection::Cell(5, 3), cx);
});
VerticalLayout {
    spacing: 8px;
    HorizontalLayout {
        spacing: 8px;
        Button {
            text: "Select row 3";
            outline: true;
            clicked => {
                sheet.selected-row = 2;
            }
        }
        Button {
            text: "Clear";
            outline: true;
            clicked => {
                sheet.clear-selection();
            }
        }
    }
    sheet := DataTable {
        height: 180px;
        columns: [
            { key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
            { key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
            { key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
        ];
        rows: [
            root.invoice("INV001", "Paid", Status.success, "$250"),
            root.invoice("INV002", "Pending", Status.warning, "$150"),
            root.invoice("INV003", "Unpaid", Status.danger, "$350"),
            root.invoice("INV004", "Paid", Status.success, "$90"),
        ];
    }
    Text {
        text: sheet.selected-row < 0 ? "selected_row: none" : "selected_row: \{sheet.selected-row}";
        color: Theme.muted-foreground;
        font-size: Theme.font-size-sm;
    }
}

The positional getters selected_row(), selected_col() and selected_cell() each answer only for their own kind of selection. selected_row() is Some only when a row itself is selected; a selected cell does not surface through it, and selecting a row clears what selected_cell() reports. When you need the row a selected cell sits in, map it from the cell:

let row_ix = state.read(cx).selected_cell().map(|(row_ix, _)| row_ix);
SlintNo Slint version of this example yet.

Keyboard navigation remembers the last row and column position across mode changes. Pressing Down after selecting a column continues from the row that was selected before, even though selected_row() returned None in column mode.

#Column Resizing and Moving

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Slint drags a handle on the header and writes the new width back. iced has
// no drag delta to hand a controlled widget, so the width is what the caller
// sets: 120 and 120 here, and the amount taking the rest.
let resizing = body_box(
    data_table(
        app,
        section::RESIZING,
        vec![
            Column::new("invoice", "Invoice").width(120.0).sortable(),
            Column::new("status", "Status").width(120.0).sortable(),
            Column::new("amount", "Amount").align(Align::End).sortable(),
        ],
        INVOICES,
    )
    .height(Length::Fixed(150.0)),
);

Enable dynamic column management:

// Configure table features
let state = cx.new(|cx| {
    TableState::new(delegate, window, cx)
        .col_resizable(true)  // Allow column resizing
        .col_movable(true)    // Allow column reordering
        .sortable(true)       // Enable sorting
        .col_selectable(true) // Allow column selection
        .row_selectable(true) // Allow row selection
});

// Listen for column changes
cx.subscribe_in(&state, window, |view, table, event, _, cx| {
    match event {
        TableEvent::ColumnWidthsChanged(widths) => {
            // Save column widths to user preferences
            save_column_widths(widths);
        }
        TableEvent::MoveColumn(from_ix, to_ix) => {
            // Save column order
            save_column_order(from_ix, to_ix);
        }
        _ => {}
    }
}).detach();
DataTable {
    height: 180px;
    col-resizable: true;
    columns: [
        { key: "invoice", title: "Invoice", width: 120px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
        { key: "status", title: "Status", width: 120px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
        { key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
    ];
    rows: [
        root.invoice("INV001", "Paid", Status.success, "$250"),
        root.invoice("INV002", "Pending", Status.warning, "$150"),
        root.invoice("INV003", "Unpaid", Status.danger, "$350"),
    ];
}

#Infinite Loading / Pagination

Implement loading more data as user scrolls:

impl TableDelegate for MyTableDelegate {
    fn has_more(&self, _: &App) -> bool {
        self.has_more_data
    }

    fn load_more_threshold(&self) -> usize {
        50 // Load more when 50 rows from bottom
    }

    fn load_more(&mut self, _: &mut Window, cx: &mut Context<TableState<Self>>) {
        if self.loading {
            return; // Prevent multiple loads
        }

        self.loading = true;

        // Spawn async task to load data
        cx.spawn(async move |view, cx| {
            let new_data = fetch_more_data().await;

            cx.update(|cx| {
                view.update(cx, |view, _| {
                    let delegate = view.table.delegate_mut();
                    delegate.data.extend(new_data);
                    delegate.loading = false;
                    delegate.has_more_data = !new_data.is_empty();
                });
            })
        }).detach();
    }

    fn loading(&self, _: &App) -> bool {
        self.loading
    }
}
SlintNo Slint version of this example yet.

#Table Styling

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The three appearance switches the Slint example offers: a size for the row
// height and the cell padding, `stripe` for the alternating tint, and
// `bordered` for the edge and the rounded corners.
let styling = body_box(
    data_table(app, section::STYLING, invoice_columns(), INVOICES)
        .size(ControlSize::Large)
        .stripe(true)
        .bordered(true)
        .height(Length::Fixed(190.0)),
);

Customize table appearance. DataTable implements Sizable: use preset sizes such as .small() and .large() for standard density, or pass a custom pixel size to set a uniform header and body row height.

use gpui_kit::px;
use gpui_kit::component::Sizable as _;

let state = cx.new(|cx| {
    TableState::new(delegate, window, cx)
});

// In render
DataTable::new(&state)
    .with_size(px(48.))             // Custom uniform row height
    .stripe(true)                   // Alternating row colors
    .bordered(true)                 // Border around table
    .scrollbar_visible(true, true)  // Vertical, horizontal scrollbars
DataTable {
    height: 220px;
    size: ControlSize.large;
    stripe: true;
    bordered: true;
    columns: [
        { key: "invoice", title: "Invoice", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "amount", title: "Amount", width: 0, align: TextHorizontalAlignment.right, sortable: true, resizable: false },
    ];
    rows: [
        root.invoice("INV001", "Paid", Status.success, "$250"),
        root.invoice("INV002", "Pending", Status.warning, "$150"),
        root.invoice("INV003", "Unpaid", Status.danger, "$350"),
        root.invoice("INV004", "Paid", Status.success, "$90"),
    ];
}

#Financial Data Table

struct StockData {
    symbol: String,
    price: f64,
    change: f64,
    change_percent: f64,
    volume: u64,
}

impl TableDelegate for StockTableDelegate {
    fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context<TableState<Self>>) -> impl IntoElement {
        let stock = &self.stocks[row_ix];
        let col = &self.columns[col_ix];

        match col.key.as_ref() {
            "symbol" => div().font_weight(FontWeight::BOLD).child(stock.symbol.clone()),
            "price" => div().text_right().child(format!("${:.2}", stock.price)),
            "change" => {
                let color = if stock.change >= 0.0 { cx.theme().green } else { cx.theme().red };
                div()
                    .text_right()
                    .text_color(color)
                    .child(format!("{:+.2}", stock.change))
            }
            "change_percent" => {
                let color = if stock.change_percent >= 0.0 { cx.theme().green } else { cx.theme().red };
                div()
                    .text_right()
                    .text_color(color)
                    .child(format!("{:+.1}%", stock.change_percent * 100.0))
            }
            "volume" => div().text_right().child(format!("{:,}", stock.volume)),
            _ => div(),
        }
    }
}
DataTable {
    height: 280px;
    stripe: true;
    col-resizable: true;
    columns: [
        { key: "symbol", title: "Symbol", width: 88px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
        { key: "name", title: "Name", width: 180px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
        { key: "price", title: "Price", width: 88px, align: TextHorizontalAlignment.right, sortable: true, resizable: true },
        { key: "change", title: "Chg", width: 80px, align: TextHorizontalAlignment.right, sortable: true, resizable: true },
        { key: "volume", title: "Volume", width: 120px, align: TextHorizontalAlignment.right, sortable: true, resizable: true },
        { key: "market_cap", title: "Market Cap", width: 120px, align: TextHorizontalAlignment.right, sortable: true, resizable: true },
    ];
    rows: [
        root.quote("AAPL", "Apple Inc.", "228.52", "+1.24", true, "52,430,100", "3,480,000"),
        root.quote("NVDA", "NVIDIA Corporation", "134.81", "-0.62", false, "180,220,400", "3,310,000"),
        root.quote("TSLA", "Tesla, Inc.", "251.44", "+3.18", true, "98,110,250", "802,000"),
        root.quote("MSFT", "Microsoft Corporation", "428.02", "+0.41", true, "22,540,800", "3,180,000"),
        root.quote("AMZN", "Amazon.com, Inc.", "186.33", "-1.07", false, "41,220,600", "1,950,000"),
        root.quote("700.HK", "Tencent Holdings", "412.60", "+0.87", true, "18,440,200", "3,820,000"),
    ];
}
// Six columns of quotes, the change in the success and danger colours, and
// the stripe the Slint example asks for on a wide table.
let financial = body_box(
    data_table(
        app,
        section::FINANCIAL,
        vec![
            Column::new("symbol", "Symbol").width(88.0).sortable(),
            Column::new("name", "Name").width(180.0).sortable(),
            Column::new("price", "Price").width(88.0).align(Align::End).sortable(),
            Column::new("change", "Chg").width(80.0).align(Align::End).sortable(),
            Column::new("volume", "Volume").width(120.0).align(Align::End).sortable(),
            Column::new("market_cap", "Market Cap")
                .width(120.0)
                .align(Align::End)
                .sortable(),
        ],
        QUOTES,
    )
    .stripe(true)
    .height(Length::Fixed(250.0)),
);

#User Management Table

struct UserTableDelegate {
    users: Vec<User>,
    columns: Vec<Column>,
}

impl UserTableDelegate {
    fn new() -> Self {
        Self {
            users: Vec::new(),
            columns: vec![
                Column::new("avatar", "").width(50.).resizable(false).movable(false),
                Column::new("name", "Name").width(150.).sortable().fixed_left(),
                Column::new("email", "Email").width(200.).sortable(),
                Column::new("role", "Role").width(100.).sortable(),
                Column::new("status", "Status").width(100.),
                Column::new("last_login", "Last Login").width(120.).sortable(),
                Column::new("actions", "Actions").width(100.).resizable(false),
            ],
        }
    }
}
DataTable {
    height: 240px;
    columns: [
        { key: "name", title: "Name", width: 160px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
        { key: "email", title: "Email", width: 180px, align: TextHorizontalAlignment.left, sortable: true, resizable: true },
        { key: "role", title: "Role", width: 90px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "status", title: "Status", width: 100px, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
        { key: "last_login", title: "Last Login", width: 0, align: TextHorizontalAlignment.left, sortable: true, resizable: false },
    ];
    rows: [
        root.person("Ada Lovelace", "ada@uni.dev", "Admin", "Active", Status.success, "2026-03-02"),
        root.person("Grace Hopper", "grace@uni.dev", "Editor", "Invited", Status.info, "2026-01-18"),
        root.person("Alan Turing", "alan@uni.dev", "Viewer", "Suspended", Status.danger, "2025-11-04"),
        root.person("Katherine Johnson", "katherine@uni.dev", "Editor", "Active", Status.success, "2026-02-11"),
        root.person("Edsger Dijkstra", "edsger@uni.dev", "Admin", "Active", Status.success, "2025-12-20"),
    ];
}
// The five-column people table, with the role and the status drawn from the
// same cell status the rest of the demo uses.
let users = body_box(
    data_table(
        app,
        section::USERS,
        vec![
            Column::new("name", "Name").width(160.0).sortable(),
            Column::new("email", "Email").width(180.0).sortable(),
            Column::new("role", "Role").width(90.0).sortable(),
            Column::new("status", "Status").width(100.0).sortable(),
            Column::new("last_login", "Last Login").sortable(),
        ],
        USERS,
    )
    .height(Length::Fixed(210.0)),
);

#Cell Selection

Enable individual cell selection for more granular control:

let state = cx.new(|cx| {
    TableState::new(delegate, window, cx)
        .cell_selectable(true)  // Enable cell selection
        .row_selectable(true)   // Also allow row selection
});

// Listen for cell events
cx.subscribe_in(&state, window, |view, table, event, _, cx| {
    match event {
        TableEvent::SelectCell(row_ix, col_ix) => {
            println!("Selected cell: ({}, {})", row_ix, col_ix);
        }
        TableEvent::DoubleClickedCell(row_ix, col_ix) => {
            // Open editor or detail view
            open_cell_editor(row_ix, col_ix);
        }
        TableEvent::RightClickedCell(row_ix, col_ix) => {
            // Show cell-specific context menu
            show_cell_context_menu(row_ix, col_ix);
        }
        TableEvent::ClearSelection => {
            println!("Selection cleared");
        }
        _ => {}
    }
}).detach();
SlintNo Slint version of this example yet.

#Cell Selection Features

When cell selection is enabled:

  • Click to select: Click on any cell to select it
  • Row selector column: A dedicated column appears on the left for selecting entire rows
  • Keyboard navigation: Arrow keys navigate between cells (not rows/columns)
  • Double-click support: Trigger actions like editing by double-clicking cells
  • Right-click support: Show context menus specific to cell content
  • Visual feedback: Selected cells show highlight with border

#Programmatic Cell Selection

// Get the currently selected cell. `None` while a row or column is
// selected instead; see "Reading and Writing the Selection" above.
if let Some((row_ix, col_ix)) = state.read(cx).selected_cell() {
    println!("Current cell: ({}, {})", row_ix, col_ix);
}

// Select a specific cell programmatically
state.update(cx, |state, cx| {
    state.set_selected_cell(5, 3, cx);  // Select row 5, column 3
});

// Clear all selections
state.update(cx, |state, cx| {
    state.clear_selection(cx);
});
SlintNo Slint version of this example yet.

#Non-selectable Columns

Prevent specific columns from being selected (useful for action columns):

Column::new("actions", "Actions")
    .width(100.)
    .selectable(false)  // This column's cells cannot be selected
    .resizable(false)
SlintNo Slint version of this example yet.

#Cell Selection with Custom Rendering

impl TableDelegate for MyTableDelegate {
    fn render_td(&mut self, row_ix: usize, col_ix: usize, _: &mut Window, cx: &mut Context<TableState<Self>>) -> impl IntoElement {
        let row = &self.data[row_ix];
        let col = &self.columns[col_ix];

        // Render different content based on whether cell is selected
        let is_selected = cx.entity().read(cx).selected_cell() == Some((row_ix, col_ix));

        match col.key.as_ref() {
            "editable_field" => {
                if is_selected {
                    // Show input when selected
                    Input::new(format!("cell-{}-{}", row_ix, col_ix))
                        .value(row.field_value.clone())
                        .into_any_element()
                } else {
                    // Show plain text when not selected
                    div().child(row.field_value.clone()).into_any_element()
                }
            }
            _ => div().child(row.get_value(col.key.as_ref())).into_any_element()
        }
    }
}
SlintNo Slint version of this example yet.

#Keyboard Shortcuts

#Row Selection Mode (default)

  • ↑/↓ - Navigate rows
  • ←/→ - Navigate columns
  • Home - Jump to first row/column
  • End - Jump to last row/column
  • PageUp/PageDown - Navigate by page
  • Escape - Clear selection

#Cell Selection Mode

  • ↑/↓ - Navigate up/down within current column
  • ←/→ - Navigate left/right within current row
  • Tab - Move to next cell (right, then next row)
  • Shift+Tab - Move to previous cell
  • Home - Jump to first cell in current row
  • End - Jump to last cell in current row
  • PageUp/PageDown - Navigate by page within current column
  • Escape - Clear selection

#API Reference

#Core Types

#Column Types

#Methods

#TableState

  • new(delegate, window, cx) - Create a new table state
  • cell_selectable(bool) - Enable/disable cell selection
  • row_selectable(bool) - Enable/disable row selection
  • col_selectable(bool) - Enable/disable column selection
  • selection() - Get the current selection as a TableSelection
  • set_selection(selection, cx) - Set the selection from a TableSelection; TableSelection::None clears it
  • selected_cell() - Get the selected cell; None unless a cell is selected
  • set_selected_cell(row_ix, col_ix, cx) - Select a specific cell
  • selected_row() - Get the selected row; None unless a row itself is selected
  • selected_col() - Get the selected column; None unless a column itself is selected
  • clear_selection(cx) - Clear all selections
  • scroll_to_row(row_ix, cx) - Scroll to specific row
  • scroll_to_col(col_ix, cx) - Scroll to specific column

#Column

  • new(key, name) - Create a new column
  • width(pixels) - Set column width
  • sortable() - Make column sortable
  • ascending() - Set default sort to ascending
  • descending() - Set default sort to descending
  • text_right() - Right-align column text
  • text_center() - Center-align column text
  • fixed(ColumnFixed) - Pin column to left
  • resizable(bool) - Enable/disable column resizing
  • movable(bool) - Enable/disable column moving
  • selectable(bool) - Enable/disable column/cell selection
  • paddings(edges) - Set custom padding
  • min_width(pixels) - Set minimum width
  • max_width(pixels) - Set maximum width

#Events

  • SelectRow(usize) - Row selected
  • DoubleClickedRow(usize) - Row double-clicked
  • SelectColumn(usize) - Column selected
  • SelectCell(usize, usize) - Cell selected (row_ix, col_ix)
  • DoubleClickedCell(usize, usize) - Cell double-clicked (row_ix, col_ix)
  • RightClickedCell(usize, usize) - Cell right-clicked (row_ix, col_ix)
  • RightClickedRow(Option<usize>) - Row right-clicked
  • ColumnWidthsChanged(Vec<Pixels>) - Column widths changed
  • MoveColumn(usize, usize) - Column moved (from_ix, to_ix)

#ColumnSort

default, ascending, descending

#DataTableColumn

A column of a `DataTable`. `width: 0` fills the remaining space (100px minimum); `sortable` shows the sort indicator and makes the header cycle Default → Descending → Ascending.

NameKindTypeDefault
keyfieldstring
titlefieldstring
widthfieldlength
alignfieldTextHorizontalAlignment
sortablefieldbool
resizablefieldbool

#DataTableCell

A cell value. `status` colors the text (e.g. gains and losses).

NameKindTypeDefault
textfieldstring
statusfieldStatus

#DataTableRow

NameKindTypeDefault
idfieldstring
cellsfield[DataTableCell]

#DataTable

A data table for large, interactive data sets. Mirrors GPUI Component's `DataTable`: typed columns and rows, a fixed header over a virtualized body (only the visible rows are instantiated, so thousands of rows stay cheap), sortable and resizable columns, row selection with the keyboard (Up/Down, Home/End, PageUp/PageDown, Escape), stripes, borders and sizes. A header click cycles Default → Descending → Ascending. When `TableSort` is installed and `row-order` is empty, the table reorders itself. Otherwise sorting stays with the application, as in GPUI's `perform_sort`: handle `sort-changed` and either reorder `rows` or pass the display order in `row-order` (indices into `rows`; empty means the natural order).

NameKindTypeDefaultDescription
columnsin-out[DataTableColumn]
rowsin[DataTableRow]
row-orderin[int]
sizeinControlSizeControlSize.medium
stripeinbool
borderedinbooltrue
row-selectableinbooltrue
col-resizableinbooltrue
empty-textinstring"No data"
selected-rowin-outint-1The selected row as a display index, or -1.
sort-columnin-outint-1
sortin-outColumnSortColumnSort.default
first-visible-rowoutintfirst-row
row-countoutintrow-order.length > 0 ? row-order.length : rows.length
sort-changedcallback(/* column */ int, ColumnSort)
row-selectedcallback(/* display index */ int)
row-double-clickedcallback(/* display index */ int)