TreeView
Hierarchical navigation over a flattened row model, virtualized and walked entirely from the keyboard.
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 420px; height: 340px; background: Tokens.color-background;
in-out property <bool> src-open: true; in-out property <bool> components-open: false;
// The three shapes this tree can take. Flattening is the consumer's job — // a collapsed branch's children are absent from `nodes`, not hidden in it. property <[TreeNode]> closed: [ { label: "src", id: "src", depth: 0, parent: -1, expandable: true }, { label: "README.md", id: "readme", depth: 0, parent: -1 }, ]; property <[TreeNode]> src-only: [ { label: "src", id: "src", depth: 0, parent: -1, expandable: true, expanded: true }, { label: "components", id: "components", depth: 1, parent: 0, expandable: true }, { label: "main.slint", id: "main", depth: 1, parent: 0 }, { label: "README.md", id: "readme", depth: 0, parent: -1 }, ]; property <[TreeNode]> all-open: [ { label: "src", id: "src", depth: 0, parent: -1, expandable: true, expanded: true }, { label: "components", id: "components", depth: 1, parent: 0, expandable: true, expanded: true }, { label: "button.slint", id: "button", depth: 2, parent: 1 }, { label: "card.slint", id: "card", depth: 2, parent: 1 }, { label: "main.slint", id: "main", depth: 1, parent: 0 }, { label: "README.md", id: "readme", depth: 0, parent: -1 }, ];
VerticalLayout { padding: 24px;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-md;
VerticalLayout { padding: 8px;
TreeView { accessible-label: "Project files"; nodes: root.src-open ? (root.components-open ? root.all-open : root.src-only) : root.closed; selected: "main"; toggled(id, expanded) => { if (id == "src") { root.src-open = expanded; } if (id == "components") { root.components-open = expanded; } } } } } }}Usage
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component AppWindow inherits Window { width: 360px; height: 400px; background: Tokens.color-background;
// Every node whose ancestors are all expanded, in display order. in property <[TreeNode]> visible-rows; in-out property <string> selected-id;
callback set-expanded(string, bool); callback open(string);
TreeView { accessible-label: "Project files"; nodes: root.visible-rows; selected <=> root.selected-id; toggled(id, expanded) => { root.set-expanded(id, expanded); } activated(id) => { root.open(id); } }}A tree here is not nested elements. Slint cannot instantiate a component
recursively, and a virtualized row cannot own a slot, so the tree is a flat
array of rows — each carrying the depth that draws its indent and the parent
that Left climbs to. The rows you hand over are exactly the rows the
tree draws: a collapsed branch’s descendants are absent from nodes, not hidden
inside it, because a virtualized list pays for the rows it is given (ADR-0016).
Flattening is therefore yours, the same trade
DataTable makes for sorting and paging.
TreeNode is a data type rather than a component:
| Field | What it is |
|---|---|
label | What the row shows and what a screen reader announces |
id | What selected, toggled and activated speak in |
depth | Nesting level, 0 at the top — it draws the indent |
parent | Index of the row this one hangs under (-1 at the top level) |
expandable | Marks a branch: it gets a chevron and answers the expand action |
expanded | Whether its children follow it in the model |
selected is the state and activated is the event that changed it — the tree
keeps a selection the way Sidebar keeps an active row, rather than firing a
one-shot pick the way Command’s selected(id) does.
The tree never flips expanded itself either: the shape of the model stays
yours.
Examples
Selection
A click, Enter or Space commits the highlighted row into
the selection: selected updates first, then activated(id) fires. Compare
selected against nodes[i].id.
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 440px; height: 320px; background: Tokens.color-background;
in-out property <string> selected: "billing"; property <string> opened: "Nothing opened yet.";
VerticalLayout { padding: 24px; spacing: 12px;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-md;
VerticalLayout { padding: 8px;
TreeView { accessible-label: "Settings"; selected <=> root.selected; nodes: [ { label: "Account", id: "account", depth: 0, parent: -1, expandable: true, expanded: true }, { label: "Profile", id: "profile", depth: 1, parent: 0 }, { label: "Billing", id: "billing", depth: 1, parent: 0 }, { label: "Workspace", id: "workspace", depth: 0, parent: -1 }, ]; activated(id) => { root.opened = "Opened " + id + "."; } } } }
Text { text: root.opened; color: Tokens.color-muted-foreground; font-size: Tokens.typography-body-sm-size; } }}Indent and row height
indent is the horizontal offset one nesting level adds, and row-height is
both the height of a row and the step the highlight scrolls by. The chevron slot
is held whether or not a row has one, so leaves and branches line up down a
level.
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 440px; height: 320px; background: Tokens.color-background;
VerticalLayout { padding: 24px;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-md;
VerticalLayout { padding: 8px;
TreeView { accessible-label: "Taxonomy"; indent: 32px; row-height: 40px; selected: "mammals"; nodes: [ { label: "Animals", id: "animals", depth: 0, parent: -1, expandable: true, expanded: true }, { label: "Mammals", id: "mammals", depth: 1, parent: 0, expandable: true, expanded: true }, { label: "Bats", id: "bats", depth: 2, parent: 1 }, { label: "Whales", id: "whales", depth: 2, parent: 1 }, { label: "Birds", id: "birds", depth: 1, parent: 0 }, ]; } } } }}A tree taller than its slot
The rows are virtualized through
ListView: the tree is content-sized
while the surrounding layout leaves it free, and scrolls once the layout
constrains its height — the large-model case. Walking past the bottom edge with
Down scrolls the highlight into view rather than losing it.
import { TreeNode, TreeView } from "@glint/components/tree-view.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 440px; height: 300px; background: Tokens.color-background;
VerticalLayout { padding: 24px; alignment: stretch;
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-md;
VerticalLayout { padding: 8px;
TreeView { accessible-label: "Modules"; // The slot is shorter than the rows, so the tree scrolls. vertical-stretch: 1; selected: "auth"; nodes: [ { label: "packages", id: "packages", depth: 0, parent: -1, expandable: true, expanded: true }, { label: "auth", id: "auth", depth: 1, parent: 0 }, { label: "billing", id: "billing", depth: 1, parent: 0 }, { label: "core", id: "core", depth: 1, parent: 0 }, { label: "email", id: "email", depth: 1, parent: 0 }, { label: "search", id: "search", depth: 1, parent: 0 }, { label: "storage", id: "storage", depth: 1, parent: 0 }, { label: "telemetry", id: "telemetry", depth: 1, parent: 0 }, { label: "ui", id: "ui", depth: 1, parent: 0 }, { label: "LICENSE", id: "license", depth: 0, parent: -1 }, ]; } } } }}Starting the keyboard on the selection
highlighted-index is the row the keyboard stands on; committing it is what
selects. Slint cannot search nodes for an id, so the tree cannot start the
keyboard on a selection it was handed — you built the array, so you know the
index. Set the two together.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { TreeNode, TreeView } from "@glint/components/tree-view.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 440px; height: 320px; background: Tokens.color-background;
in-out property <string> selected: "core"; in-out property <int> highlighted: 3;
VerticalLayout { padding: 24px; spacing: 12px;
Button { variant: ButtonVariant.outline; text: "Jump to “auth”"; clicked => { root.selected = "auth"; root.highlighted = 1; } }
Rectangle { border-width: 1px; border-color: Tokens.color-border-hairline; border-radius: Tokens.radius-md;
VerticalLayout { padding: 8px;
TreeView { accessible-label: "Modules"; selected <=> root.selected; highlighted-index <=> root.highlighted; nodes: [ { label: "packages", id: "packages", depth: 0, parent: -1, expandable: true, expanded: true }, { label: "auth", id: "auth", depth: 1, parent: 0 }, { label: "billing", id: "billing", depth: 1, parent: 0 }, { label: "core", id: "core", depth: 1, parent: 0 }, ]; } } } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
nodes | in [TreeNode] | no default | The visible rows, in display order: every node whose ancestors are all expanded. The consumer owns the flattening. |
selected | in-out string | no default | Two-way; id of the selected row. Compare against nodes[i].id. |
highlighted-index | in-out int | 0 | Row the keyboard stands on. Committing it (Enter, Space, a click) is what selects. A click brings it along, so the one time to set it is alongside a selected the consumer assigns itself: Slint cannot search nodes for an id, so the tree cannot start the keyboard on a selection it was handed — the consumer, who built the array, knows the index. |
row-height | in length | 32px | Height of one row; also the step the highlight scrolls by. |
indent | in length | Tokens.spacing-lg | Horizontal offset one nesting level adds. |
Callbacks
| Callback | Description |
|---|---|
toggled(string, bool) | Fired with a row's id and the expansion it asks for. The tree never flips expanded itself: the shape of the model stays the consumer's. |
activated(string) | Fired with a row's id when the user selects it; selected updates first. |
Accessibility
- Tree role. The root carries Slint’s
treerole and the whole row count; naming it is the call site’s, so setaccessible-label. - Rows are selectable, expandable items. Each row is a
list-itemwith its index, its label, and — on a branch —accessible-expandableandaccessible-expanded. Selecting it is its default action and opening or closing it is its expand action, so assistive technology can do either without doing both. - The level is spelled out. Slint 1.17 has no accessible level, so each row carries “Level 2” as its description — otherwise the hierarchy the indent draws would be visual only.
- Where the platform stops. The
treerole does reach both real backends (winit maps it to AccessKit’sRole::Tree, Qt toQAccessible::Tree). What is missing is below the root: there is no tree-item role, so a row can only be a list item. A screen reader therefore hears a tree of expandable list items that each say which level they are on, rather than a tree whose items carry their level structurally. - Keyboard. The tree is one tab stop. Up / Down walk the rows and Home / End reach the ends, all clamped — a tree does not wrap. Right opens a closed branch and, once open, steps into it; Left closes an open branch and otherwise climbs to the parent. Enter and Space commit the highlight into the selection.
- The highlight is not the selection. A tree nobody has focused does not sit there with row 0 lit up competing with the selection: the highlight is drawn only while the tree holds the keyboard. A click brings it along.
- Keyboard-only focus ring, around the row the arrows landed on rather than around the whole tree.