DataTable
A sortable, selectable, pageable table with hideable columns and a virtualized body — the component owns the UX, your model owns the data.
import { DataTable, DataTableRow } from "@glint/components/data-table.slint";import { TableAlign, TableCellKind } from "@glint/components/table-cell.slint";import { BadgeVariant } from "@glint/components/badge.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 700px; height: 400px; background: Tokens.color-background;
in-out property <[DataTableRow]> rows: [ { kind: TableCellKind.badge, text: "Success" }, { text: "$316.00" }, ] }, { kind: TableCellKind.badge, text: "Success" }, { text: "$242.00" }, ] }, { kind: TableCellKind.badge, text: "Processing", badge-variant: BadgeVariant.secondary }, { text: "$837.00" }, ] }, { kind: TableCellKind.badge, text: "Failed", badge-variant: BadgeVariant.destructive }, { text: "$721.00" }, ] }, ];
VerticalLayout { padding: 24px; alignment: start;
DataTable { accessible-label: "Payments"; columns: [ { title: "Status", width: 130px }, { title: "Email" }, { title: "Amount", align: TableAlign.end, width: 120px }, ]; rows <=> root.rows; selectable: true; selected-count: (root.rows[0].selected ? 1 : 0) + (root.rows[1].selected ? 1 : 0) + (root.rows[2].selected ? 1 : 0) + (root.rows[3].selected ? 1 : 0); all-rows-selected(on) => { root.rows[0].selected = on; root.rows[1].selected = on; root.rows[2].selected = on; root.rows[3].selected = on; } } }}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 settledsort-columnandsort-descfor 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-countandrow-countare sums only you can take, for the same reason; leaverow-countat0and 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.
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 Demo inherits Window { width: 620px; height: 320px; background: Tokens.color-background;
in-out property <int> sort-column: 0; in-out property <bool> sort-desc: false;
property <[DataTableRow]> by-name: [ { cells: [{ text: "Analytics" }, { text: "$1,120" }] }, { cells: [{ text: "Platform" }, { text: "$3,400" }] }, { cells: [{ text: "Support" }, { text: "$860" }] }, ]; property <[DataTableRow]> by-name-desc: [ { cells: [{ text: "Support" }, { text: "$860" }] }, { cells: [{ text: "Platform" }, { text: "$3,400" }] }, { cells: [{ text: "Analytics" }, { text: "$1,120" }] }, ]; property <[DataTableRow]> by-spend: [ { cells: [{ text: "Support" }, { text: "$860" }] }, { cells: [{ text: "Analytics" }, { text: "$1,120" }] }, { cells: [{ text: "Platform" }, { text: "$3,400" }] }, ]; property <[DataTableRow]> by-spend-desc: [ { cells: [{ text: "Platform" }, { text: "$3,400" }] }, { cells: [{ text: "Analytics" }, { text: "$1,120" }] }, { cells: [{ text: "Support" }, { text: "$860" }] }, ];
VerticalLayout { padding: 24px; alignment: start;
DataTable { accessible-label: "Spend by team"; columns: [ { title: "Team" }, { title: "Spend", align: TableAlign.end, width: 120px }, ]; rows: root.sort-column == 1 ? (root.sort-desc ? root.by-spend-desc : root.by-spend) : (root.sort-desc ? root.by-name-desc : root.by-name); sort-column <=> root.sort-column; sort-desc <=> root.sort-desc; } }}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.
import { DataTable, DataTableRow } from "@glint/components/data-table.slint";import { TableAlign, TableCellKind } from "@glint/components/table-cell.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 660px; height: 340px; background: Tokens.color-background;
property <string> last: "Nothing taken yet.";
in-out property <[DataTableRow]> rows: [ { label: "api-gateway", cells: [ { kind: TableCellKind.icon, text: "Healthy", icon: IconSet.CircleCheck }, { text: "api-gateway" }, { kind: TableCellKind.checkbox, checked: true }, { text: "1.4 ms" }, { kind: TableCellKind.actions, actions: [ { label: "Restart" }, { label: "View logs" }, ] }, ] }, { label: "image-worker", cells: [ { kind: TableCellKind.icon, text: "Degraded", icon: IconSet.TriangleAlert }, { text: "image-worker" }, { kind: TableCellKind.checkbox }, { text: "38 ms" }, { kind: TableCellKind.actions, actions: [ { label: "Restart" }, { label: "View logs" }, ] }, ] }, ];
VerticalLayout { padding: 24px; spacing: 12px; alignment: start;
DataTable { accessible-label: "Services"; columns: [ { title: "Health", align: TableAlign.center, width: 90px }, { title: "Service" }, { title: "Paged", align: TableAlign.center, width: 90px }, { title: "p99", align: TableAlign.end, width: 90px }, { title: "", align: TableAlign.end, width: 60px }, ]; rows <=> root.rows; cell-toggled(row, column, on) => { root.last = "Row " + row + " paging " + (on ? "on" : "off") + "."; } cell-action(row, column, entry, child) => { root.last = "Action " + entry + " on row " + row + "."; } }
Text { text: root.last; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}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.
import { DataTable, DataTableRow } from "@glint/components/data-table.slint";import { TableAlign, TableColumn } from "@glint/components/table-cell.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 660px; height: 340px; background: Tokens.color-background;
in-out property <[TableColumn]> columns: [ { title: "Invoice", width: 110px }, { title: "Customer" }, { title: "Method", width: 140px }, { title: "Amount", align: TableAlign.end, width: 110px }, ];
VerticalLayout { padding: 24px; alignment: start;
DataTable { accessible-label: "Invoices"; view-options: true; columns <=> root.columns; rows: [ { cells: [ { text: "INV001" }, { text: "Acme Corp" }, { text: "Credit Card" }, { text: "$250.00" }, ] }, { cells: [ { text: "INV002" }, { text: "Globex" }, { text: "Bank Transfer" }, { text: "$150.00" }, ] }, { cells: [ { text: "INV003" }, { text: "Initech" }, { text: "PayPal" }, { text: "$350.00" }, ] }, ]; } }}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.
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 Demo inherits Window { width: 660px; height: 340px; background: Tokens.color-background;
in-out property <int> page: 0;
property <[DataTableRow]> first-page: [ { cells: [{ text: "Ada Lovelace" }, { text: "$316.00" }] }, { cells: [{ text: "Grace Hopper" }, { text: "$242.00" }] }, ]; property <[DataTableRow]> second-page: [ { cells: [{ text: "Alan Turing" }, { text: "$837.00" }] }, { cells: [{ text: "Katherine Johnson" }, { text: "$721.00" }] }, ]; property <[DataTableRow]> third-page: [ { cells: [{ text: "Radia Perlman" }, { text: "$188.00" }] }, ];
VerticalLayout { padding: 24px; alignment: start;
DataTable { accessible-label: "Payouts"; columns: [ { title: "Recipient" }, { title: "Amount", align: TableAlign.end, width: 120px }, ]; rows: root.page == 0 ? root.first-page : (root.page == 1 ? root.second-page : root.third-page); total-pages: 3; current-page <=> root.page; page-size: 2; page-size-options: [2, 5, 10]; } }}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.
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 Demo inherits Window { width: 620px; height: 300px; background: Tokens.color-background;
VerticalLayout { padding: 24px; alignment: stretch;
DataTable { accessible-label: "Build queue"; // The surrounding layout constrains the height, so the body scrolls. vertical-stretch: 1; columns: [ { title: "Build" }, { title: "Branch" }, { title: "Duration", align: TableAlign.end, width: 110px }, ]; rows: [ { cells: [{ text: "#4102" }, { text: "main" }, { text: "3m 12s" }] }, { cells: [{ text: "#4101" }, { text: "feat/tables" }, { text: "4m 02s" }] }, { cells: [{ text: "#4100" }, { text: "main" }, { text: "3m 41s" }] }, { cells: [{ text: "#4099" }, { text: "fix/scroll" }, { text: "2m 58s" }] }, { cells: [{ text: "#4098" }, { text: "main" }, { text: "3m 09s" }] }, { cells: [{ text: "#4097" }, { text: "feat/tree" }, { text: "5m 20s" }] }, { cells: [{ text: "#4096" }, { text: "main" }, { text: "3m 33s" }] }, { cells: [{ text: "#4095" }, { text: "chore/deps" }, { text: "1m 47s" }] }, ]; } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
columns | in-out [TableColumn] | no default | Columns: 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). |
rows | in-out [DataTableRow] | no default | Visible 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-column | in-out int | -1 | Current sort column index (-1 = unsorted). Two-way so the indicator stays accurate as the consumer flips state. |
sort-desc | in-out bool | false | Current sort direction; flipping is automatic when the same header is clicked twice in a row. |
selectable | in bool | false | Renders the leading selection column: a select-all box in the header and one box per row. |
selected-count | in int | 0 | How 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-count | in int | 0 | |
view-options | in bool | false | Renders the view-options menu — one checkable row per column, hiding and showing it without reshaping a single row of data. |
total-pages | in int | 1 | How 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-page | in-out int | 0 | Two-way; the active page (0-indexed). |
page-size-options | in [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-size | in-out int | 10 | |
select-all-label | in string | @tr("Select all rows") | Names for the controls the table ships. Override to translate. |
view-options-label | in string | @tr("View") | |
rows-per-page-label | in string | @tr("Rows per page") | |
first-page-label | in string | @tr("First page") | |
previous-page-label | in string | @tr("Previous page") | |
next-page-label | in string | @tr("Next page") | |
last-page-label | in string | @tr("Last page") | |
actions-label | in string | @tr("Row actions") | Name an action cell's button falls back to when it carries no text. |
Callbacks
| Callback | Description |
|---|---|
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
| Property | Type | Default | Description |
|---|---|---|---|
viewport-height | in-out length | no default | Total 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-width | in-out length | no default | Total width of that content, measured and scrolled the same way. |
viewport-y | in-out length | no default | How 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-x | in-out length | no default | The same, sideways: 0 at the leading edge, visible-width - viewport-width at the trailing one. |
visible-height | out length | no default | The window onto the content — what viewport-y and viewport-x slide. |
visible-width | out length | no default | |
scrollbar-hide-delay | in duration | 0ms | How 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-step | in length | 40px | How 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-focus | in bool | false | Whether 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-down | out bool | no default | Which 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-sideways | out bool | no default |
Callbacks
| Callback | Description |
|---|---|
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
| Function | Description |
|---|---|
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) -> length | The 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
| Property | Type | Default | Description |
|---|---|---|---|
cell | in TableCell | no default | |
align | in TableAlign | no default | Alignment inherited from the column the cell sits in. |
column | in string | no default | Title 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-index | in int | no default | Position of that column, published so a screen reader can say which one this is. |
actions-label | in string | @tr("Row actions") | Name for an action cell's button when the cell carries no text. |
Callbacks
| Callback | Description |
|---|---|
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
| Enum | Values |
|---|---|
TableAlign | start, 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
| Property | Type | Default | Description |
|---|---|---|---|
columns | in [TableColumn] | no default | |
cells | in [TableCell] | no default | |
actions-label | in string | no default |
Callbacks
| Callback | Description |
|---|---|
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
| Property | Type | Default | Description |
|---|---|---|---|
column | in TableColumn | no default |
TableHeadLabel is the header’s own type treatment, so a column title cannot
drift from the rest of the table.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
align | in TableAlign | no default | Which way the column reads, so the heading sits over its own values. |
Enums
| Enum | Values |
|---|---|
TableAlign | start, 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
| Property | Type | Default | Description |
|---|---|---|---|
inset | out length | no default |
Accessibility
- Table role. The root carries
accessible-role: tablewithaccessible-item-countas the number of rows; the body’sListViewis silenced in its favour so there is not a second list to read through. Naming the table is the call site’s — setaccessible-label. - Headers are buttons. A sortable header carries
accessible-role: buttonnamed 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-itemcarrying its index, its name (label, falling back to the first cell) and — when the table isselectable— 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 andactions-labelall ship through@tr(...)and stay overridable.