Skip to content
Glint UI

DataTable

A sortable, selectable, pageable table with hideable columns and a virtualized body — the component owns the UX, your model owns the data.

Usage

import { DataTable, DataTableRow } from "@glint/components/data-table.slint";
import { TableAlign } from "@glint/components/table-cell.slint";
import { Tokens } from "@glint/theme/tokens.slint";
export component AppWindow inherits Window {
width: 640px;
height: 360px;
background: Tokens.color-background;
// The page your model has already sorted, filtered and sliced.
in-out property <[DataTableRow]> page-of-rows;
in property <int> total-pages: 1;
in property <int> selected-count: 0;
in property <int> row-count: 0;
callback re-sort(int);
callback slice(int);
VerticalLayout {
padding: 24px;
DataTable {
accessible-label: "Payments";
columns: [
{ title: "Email" },
{ title: "Amount", align: TableAlign.end, width: 120px },
];
rows <=> root.page-of-rows;
total-pages: root.total-pages;
selectable: true;
selected-count: root.selected-count;
row-count: root.row-count;
sort(column) => { root.re-sort(column); }
page-changed(page) => { root.slice(page); }
}
}
}

The component handles the UX; you own the data. Clicking a header, ticking a row, hiding a column and stepping a page are all the table’s, and each of them reports what the user asked for — but Slint has no substring methods and no sort primitive on arrays, so filtering, sorting and slicing live in your model (ADR-0016). rows is the page you have already prepared, not the whole set.

Two of those callbacks need a word each:

  • sort(column) fires after the table has settled sort-column and sort-desc for you, including the flip that a second click on the same header means. Re-sort your model from those two.
  • all-rows-selected(on) is the one the table cannot settle for you. The rows it would have to write are the rows a virtualized body has never built, and ticking only what is on screen is worse than ticking nothing (ADR-0028) — so your model is what turns the intent into rows. selected-count and row-count are sums only you can take, for the same reason; leave row-count at 0 and the selection summary counts against the page it was handed.

Cells are TableCell values shared with Table, so a status badge, an icon, a checkbox or a per-row action menu stands in one. The selection column is the table’s own, not a cell of your model — which is why a row’s tick reports through row-selected and a checkbox cell reports through cell-toggled.

Examples

Sorting

Clicking a header fires sort(column); a second click on the same header reverses the direction. The table moves the chevron, and the model here answers by handing back a differently ordered array.

Widget cells

A TableCell’s kind picks what it draws, and align — the column’s, not the cell’s — decides where it sits inside the column’s width. A checkbox cell settles its own state in rows before cell-toggled fires, and an action cell reports the entry taken through cell-action.

Hiding columns

view-options: true puts a View menu above the table with one checkable row per column. It settles columns[i].hidden before reporting through column-visibility-changed, so the model is the state — and a hidden column collapses without reshaping a single row.

Paging

total-pages and page-size-options are what turn the pager on — more than one page to step through, or sizes to pick between. Both page-changed and page-size-changed fire after the table has settled current-page and page-size, and re-slicing the model is yours.

A model too large to draw

The body is virtualized through ListView: it is content-sized while the surrounding layout leaves the table free, and scrolls once the layout constrains its height. Only the rows in view are instantiated, so a ten-thousand-row page costs what a ten-row one does — and the keyboard highlight scrolls itself into view as the arrows walk past an edge.

API Reference

Properties

PropertyTypeDefaultDescription
columnsin-out [TableColumn]no defaultColumns: their headings, widths, alignment and visibility. Clicking a header fires sort. in-out because the view-options menu settles hidden here before reporting it — the model is the state, the way a menu's checkbox rows are (ADR-0020).
rowsin-out [DataTableRow]no defaultVisible rows (already sorted / filtered / paged by the consumer). in-out because a row's own checkbox and a checkbox cell settle here before reporting.
sort-columnin-out int-1Current sort column index (-1 = unsorted). Two-way so the indicator stays accurate as the consumer flips state.
sort-descin-out boolfalseCurrent sort direction; flipping is automatic when the same header is clicked twice in a row.
selectablein boolfalseRenders the leading selection column: a select-all box in the header and one box per row.
selected-countin int0How many rows of the whole model are ticked, and how many there are. Both are sums the consumer takes, for the same reason sorting and paging are theirs (ADR-0016): a virtualized body cannot count rows it has not instantiated. row-count left at 0 falls back to the page it was given.
row-countin int0
view-optionsin boolfalseRenders the view-options menu — one checkable row per column, hiding and showing it without reshaping a single row of data.
total-pagesin int1How many pages the model holds; the pager's steps and its page label count against it. More than one is one of the two things that turn the pager on — see page-size-options for the other.
current-pagein-out int0Two-way; the active page (0-indexed).
page-size-optionsin [int][]Rows-per-page choices the pager offers, and the one in force. Left empty the pager shows no size control — and offering sizes is itself what turns the pager on, because a model that currently fits one page is exactly when a reader wants a smaller one.
page-sizein-out int10
select-all-labelin string@tr("Select all rows")Names for the controls the table ships. Override to translate.
view-options-labelin string@tr("View")
rows-per-page-labelin string@tr("Rows per page")
first-page-labelin string@tr("First page")
previous-page-labelin string@tr("Previous page")
next-page-labelin string@tr("Next page")
last-page-labelin string@tr("Last page")
actions-labelin string@tr("Row actions")Name an action cell's button falls back to when it carries no text.

Callbacks

CallbackDescription
sort(int)Fired with the column index when a header is clicked. The component updates sort-column / sort-desc first.
row-selected(int, bool)Fired when a row's own box is ticked or cleared; the row settles first.
all-rows-selected(bool)Fired when the select-all box is used. This one the table cannot settle for you: the rows it would have to write are the rows a virtualized body has not built, and ticking only what is on screen is worse than ticking nothing (ADR-0028). Your model is what turns the intent into rows.
column-visibility-changed(int, bool)Fired with the column and whether it is visible now; columns has already settled.
page-changed(int)Fired when the user picks a different page.
page-size-changed(int)Fired with the new page size; page-size has already settled.
row-clicked(int)Fired with the row index when a row is clicked, activated from the keyboard or taken through its accessible default action.
cell-toggled(int, int, bool)A checkbox *cell* was flipped: its row, its column and the state it now holds. Distinct from row-selected, which is the selection column the table owns rather than a cell of the model.
cell-action(int, int, int, int)An action cell fired: its row, its column, the entry's index and the submenu leaf's index, or -1 when the entry itself was taken.

DataTableRow is a data type rather than a component: cells, label (what a screen reader calls the row; it falls back to the first cell) and selected (read, never written from here for a select-all). TableColumn, TableCell, TableAlign and TableCellKind are shared with Table, where their fields are spelled out.

ListView

The virtualized viewport the body scrolls in, and the one Glint publishes for row models of your own. It is ScrollArea under the one name the Slint compiler recognizes and windows: the compiler writes viewport-height and each row’s y itself, so only the rows in view are instantiated (ADR-0019). The price of the name is that a ListView takes exactly one for and nothing else — no sibling, no if, no plain child, and no @children slot per row. Content that is not a row model goes in a ScrollArea instead: same surface, same scrollbar, without the windowing.

import { ListView } from "@glint/components/list-view.slint";
import { Tokens } from "@glint/theme/tokens.slint";
export component AppWindow inherits Window {
width: 320px;
height: 240px;
background: Tokens.color-background;
in property <[string]> items;
ListView {
for item in root.items: Text {
height: 32px;
text: item;
color: Tokens.color-foreground;
vertical-alignment: center;
}
}
}

Properties

PropertyTypeDefaultDescription
viewport-heightin-out lengthno defaultTotal height of the scrollable content, measured off the children's layout unless a call site states it. It scrolls once this exceeds visible-height. A ListView has the compiler write it instead, from the rows it has instantiated.
viewport-widthin-out lengthno defaultTotal width of that content, measured and scrolled the same way.
viewport-yin-out lengthno defaultHow far the content is scrolled, as a non-positive offset — 0 at the top, visible-height - viewport-height at the bottom. Two-way, so a host that owns a keyboard cursor can scroll it into view; the wheel and the scrollbar write it too.
viewport-xin-out lengthno defaultThe same, sideways: 0 at the leading edge, visible-width - viewport-width at the trailing one.
visible-heightout lengthno defaultThe window onto the content — what viewport-y and viewport-x slide.
visible-widthout lengthno default
scrollbar-hide-delayin duration0msHow long a bar stays after the reader stops scrolling. 0ms — the default — is a bar that stays for as long as the content overflows; anything else is a bar that appears when the surface is scrolled and fades out once the reader has left it alone that long.
keyboard-stepin length40pxHow far one arrow key, or one accessible increment, moves the content. A page is a window less one of these, so what was at the edge stays on screen and the reader keeps their place.
content-takes-focusin boolfalseWhether the content has tab stops of its own — a Textarea's field, a list of rows, a menu. ADR-0032 gives this surface a stop in the reading order, and the scope that takes it wraps the content so keys the content refuses bubble out to it. But an ancestor is reached *first* by Slint's pre-order tab walk, so where the content is focusable that stop lands in front of it: Tab reached an invisible scroller instead of the field, no ring drew, and typed characters went nowhere until a second Tab. Set this where the content answers the keyboard; the surface keeps the keys it is handed and stops claiming a stop the content already owns.
scrolls-downout boolno defaultWhich axes have somewhere to go. A bar takes room from the other bar's track, so each also has to know about the other.
scrolls-sidewaysout boolno default

Callbacks

CallbackDescription
scrolled()Fires when the reader scrolls — by wheel, by thumb or from the keyboard — and never for an offset written from code, which is what lets a host tell the reader's intent apart from its own corrections.

Functions

FunctionDescription
reveal(offset: length, extent: length)Scroll just enough to bring the band from offset to offset + extent — a row, measured in the content's own coordinates — inside the window, and not a pixel further. Every Glint list that carries a keyboard highlight has the same job when the highlight walks past an edge, so the arithmetic lives here rather than three times over.
bounded-y(to: length) -> lengthThe offsets the content's own ends allow, along each axis. Offsets run non-positive, so the far end is the floor and 0 is the start; content that fits has both at 0 and every walk below is a no-op.
bounded-x(to: length) -> length

Every row above is inherited: a ListView declares nothing of its own, and what it adds to ScrollArea is the name, accessible-role: list, and the compiler’s windowing behind it. The table lists them here because this is where a reader meets a ListView — reaching through inherits is what keeps it from being a heading over nothing.

Cell primitives

What Table and DataTable both draw their columns and cells with, so the two cannot drift apart in what a cell may hold, how a column is sized or what a screen reader hears (ADR-0028). Reach for them when you are building a table surface of your own; TableGeometry is the global that holds the one inset every table’s content stands from its column’s edges.

TableCellView draws one cell: a string, or the widget a TableCell names.

Properties

PropertyTypeDefaultDescription
cellin TableCellno default
alignin TableAlignno defaultAlignment inherited from the column the cell sits in.
columnin stringno defaultTitle of that column — what a screen reader hears before the value, and the name a control with no text of its own falls back to.
column-indexin intno defaultPosition of that column, published so a screen reader can say which one this is.
actions-labelin string@tr("Row actions")Name for an action cell's button when the cell carries no text.

Callbacks

CallbackDescription
toggled(bool)A checkbox cell was flipped, with the state the user asked for.
action-selected(int, int)An action was taken: the entry's index, and the index of the submenu leaf it was taken from, or -1 when the entry itself was.

Enums

EnumValues
TableAlignstart, center, end

TableCellRow lays a row of them out against the columns’ geometry, and draws the header run as well as the body’s.

Properties

PropertyTypeDefaultDescription
columnsin [TableColumn]no default
cellsin [TableCell]no default
actions-labelin stringno default

Callbacks

CallbackDescription
toggled(int, bool)A checkbox cell in column int was flipped.
action-selected(int, int, int)An action cell in column int fired: the entry, then the submenu leaf or -1.

TableColumnCell is the box one column reserves in that row.

Properties

PropertyTypeDefaultDescription
columnin TableColumnno default

TableHeadLabel is the header’s own type treatment, so a column title cannot drift from the rest of the table.

Properties

PropertyTypeDefaultDescription
alignin TableAlignno defaultWhich way the column reads, so the heading sits over its own values.

Enums

EnumValues
TableAlignstart, center, end

TableGeometry is the global holding the one inset every table’s content stands from its column’s edges. It is read, never set: geometry belongs to the column (ADR-0028), and two tables reading two insets is how their text stopped lining up.

Properties

PropertyTypeDefaultDescription
insetout lengthno default

Accessibility

  • Table role. The root carries accessible-role: table with accessible-item-count as the number of rows; the body’s ListView is silenced in its favour so there is not a second list to read through. Naming the table is the call site’s — set accessible-label.
  • Headers are buttons. A sortable header carries accessible-role: button named by its column title, and sorting is its default action, so assistive technology can sort without the pointer. A hidden column draws no node at all.
  • Rows are items. Each row is a list-item carrying its index, its name (label, falling back to the first cell) and — when the table is selectable — whether it is in the selection. Acting on a row is its default action.
  • Cells. A cell that draws no control is announced as its column then its value (“Status: Success”) with the column’s position; a checkbox cell announces itself a checkbox and an action cell a button, each carrying the column’s position the same way. Slint 1.17 has no cell or column-header role, so that pairing is as close to header association as the platform gets.
  • Keyboard — the header strip. Tab reaches it first. Left and Right walk the columns, stepping over hidden ones, and Enter or Space sorts by the one in focus — the same flip-on-repeat rule a click follows. A keyboard-only focus ring is drawn around the column the arrows landed on, not around the whole strip.
  • Keyboard — the body. Tab again reaches the rows as one stop. Up and Down move the highlight and Home / End reach the ends, all clamped — a table walks its rows, it does not cycle them. Enter acts on the row the highlight stands on, and Space ticks it when the table is selectable. The highlight is not the selection: standing on a row implies neither.
  • Keyboard — the menus. The View menu and the rows-per-page list answer the same ladder: Up / Down wrap through the rows, Home and End are its ends, and Enter or Space takes the row under the highlight. Each opens on its first row rather than on last time’s.
  • The controls are named and translatable. select-all-label, view-options-label, rows-per-page-label, the four pager labels and actions-label all ship through @tr(...) and stay overridable.