Skip to content
ShineUI
Install

Tree

Hierarchy you can walk.

<sui-tree>DataStable

About

Standalone hierarchy for a chart of accounts, a folder tree or any outline: expand and collapse, filter as you type, and single or checkbox selection. Branches load their children on demand and rows move with the pointer or the keyboard. It is one tab stop, and the arrow keys walk the rows. aria-label or aria-labelledby on the host names it.

Usage

<sui-tree selection-mode="single" aria-label="Chart of accounts"></sui-tree> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-tree"); el.expanded = ["assets"]; /* SuiKey[] */ el.nodes = [ { key: "assets", label: "Assets", icon: "fa-regular fa-building", children: [ { key: "1000", label: "Operating Checking", meta: "$87,929.18" }, { key: "1200", label: "Accounts Receivable", meta: "$90,740.45" }, ], }, { key: "liab", label: "Liabilities", icon: "fa-regular fa-credit-card", children: [{ key: "2000", label: "Accounts Payable", meta: "$47,215.32" }] }, { key: "inc", label: "Income", icon: "fa-regular fa-money-bill-1", children: [{ key: "4000", label: "Sales", meta: "$412,880.00" }] }, ]; /* SuiTreeNode[] */ el.addEventListener("suiSelectionChange", (e) => console.log(e.detail)); </script>
import { SuiTree } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const expanded = ["assets"]; /* SuiKey[] */ const nodes = [ { key: "assets", label: "Assets", icon: "fa-regular fa-building", children: [ { key: "1000", label: "Operating Checking", meta: "$87,929.18" }, { key: "1200", label: "Accounts Receivable", meta: "$90,740.45" }, ], }, { key: "liab", label: "Liabilities", icon: "fa-regular fa-credit-card", children: [{ key: "2000", label: "Accounts Payable", meta: "$47,215.32" }] }, { key: "inc", label: "Income", icon: "fa-regular fa-money-bill-1", children: [{ key: "4000", label: "Sales", meta: "$412,880.00" }] }, ]; /* SuiTreeNode[] */ return <SuiTree selectionMode="single" aria-label="Chart of accounts" expanded={expanded} nodes={nodes} onSuiSelectionChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiTree } from "@shineui/vue"; import "@shineui/core/shineui.css"; const expanded = ["assets"]; // SuiKey[] const nodes = [ { key: "assets", label: "Assets", icon: "fa-regular fa-building", children: [ { key: "1000", label: "Operating Checking", meta: "$87,929.18" }, { key: "1200", label: "Accounts Receivable", meta: "$90,740.45" }, ], }, { key: "liab", label: "Liabilities", icon: "fa-regular fa-credit-card", children: [{ key: "2000", label: "Accounts Payable", meta: "$47,215.32" }] }, { key: "inc", label: "Income", icon: "fa-regular fa-money-bill-1", children: [{ key: "4000", label: "Sales", meta: "$412,880.00" }] }, ]; // SuiTreeNode[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiTree selection-mode="single" aria-label="Chart of accounts" :expanded="expanded" :nodes="nodes" @sui-selection-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiTree } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiTree], template: `<sui-tree selection-mode="single" aria-label="Chart of accounts" [(selection)]="selection" [expanded]="expanded" [nodes]="nodes"></sui-tree>`, }) export class Example { selection: SuiKey[] = []; expanded = ["assets"]; // SuiKey[] nodes = [ { key: "assets", label: "Assets", icon: "fa-regular fa-building", children: [ { key: "1000", label: "Operating Checking", meta: "$87,929.18" }, { key: "1200", label: "Accounts Receivable", meta: "$90,740.45" }, ], }, { key: "liab", label: "Liabilities", icon: "fa-regular fa-credit-card", children: [{ key: "2000", label: "Accounts Payable", meta: "$47,215.32" }] }, { key: "inc", label: "Income", icon: "fa-regular fa-money-bill-1", children: [{ key: "4000", label: "Sales", meta: "$412,880.00" }] }, ]; // SuiTreeNode[] }

Basic

One highlighted row; selection holds its key and suiSelectionChange carries the new selection. The tree is a role="tree" of treeitem rows with aria-level and aria-selected, named by aria-label or aria-labelledby on the host. Metadata such as a balance sits flush right and is read as part of the row's name.

<div style="max-width: 380px"><sui-tree selection-mode="single" aria-label="Chart of accounts"></sui-tree></div>
// setup(root): properties and events setup(root) { const t = root.querySelector('sui-tree'); t.nodes = accounts(); t.expanded = ['assets']; t.selection = ['1200']; }

Checkbox and filter

checkbox mode multi-selects and each row carries aria-checked. The header box ticks every node the filter shows and reads as mixed while only some are ticked. The filter box narrows the tree as you type and opens every branch that holds a match.

<div style="max-width: 380px"><sui-tree id="tr-check" filter selection-mode="checkbox"></sui-tree></div>
// setup(root): properties and events setup(root) { const t = root.querySelector('#tr-check'); t.nodes = accounts(); t.expanded = ['assets']; t.selection = ['1200']; }

Expansion only

selection-mode="none" draws no selection at all; Enter opens or closes the branch instead. Use it for a read-only outline.

<div style="max-width: 380px"><sui-tree id="tr-none" selection-mode="none"></sui-tree></div>
// setup(root): properties and events setup(root) { const t = root.querySelector('#tr-none'); t.nodes = accounts(); t.expanded = ['assets', 'liab']; }

Loading

loading puts one row in place of the tree, the filter box and the select-all header, since nothing is there to filter or tick. An empty tree shows emptyMessage, which says Nothing here yet; a filter that matches none shows emptyFilterMessage, No matches. Two states, and one sentence cannot be right about both.

<div style="display:grid;gap:10px;max-width:380px"> <sui-button id="tr-reload" variant="outline" size="sm" label="Load again"></sui-button> <sui-tree id="tr-loading" selection-mode="single" loading></sui-tree> </div>
// setup(root): properties and events setup(root) { const t = root.querySelector('#tr-loading'); const load = () => { t.nodes = []; t.loading = true; setTimeout(() => { t.nodes = accounts(); t.expanded = ['assets']; t.loading = false; }, 1200); }; load(); root.querySelector('#tr-reload').addEventListener('click', load); }

Loading children on demand

A node with leaf: false is a branch whose children have not arrived. Opening it fires suiLoad with the node; answer by setting its loading flag, then its children, and assigning a new nodes array. The branch shows a spinner and reads aria-busy while it waits.

Open a branch to fetch it.
<div style="display:grid;gap:10px;max-width:380px"> <code id="tr-lazy-log" style="font:460 12px/1.6 var(--font-sans);color:var(--ink-muted)">Open a branch to fetch it.</code> <sui-tree id="tr-lazy" selection-mode="single"></sui-tree> </div>
// setup(root): properties and events setup(root) { const t = root.querySelector('#tr-lazy'); const log = root.querySelector('#tr-lazy-log'); const kids = { assets: [ { key: '1000', label: 'Operating Checking', meta: '$87,929.18' }, { key: '1200', label: 'Accounts Receivable', meta: '$90,740.45' }, ], liab: [{ key: '2000', label: 'Accounts Payable', meta: '$47,215.32' }], }; t.nodes = [ { key: 'assets', label: 'Assets', icon: 'fa-regular fa-building', leaf: false }, { key: 'liab', label: 'Liabilities', icon: 'fa-regular fa-credit-card', leaf: false }, ]; const mark = (key, patch) => { t.nodes = t.nodes.map((n) => (n.key === key ? { ...n, ...patch } : n)); }; t.addEventListener('suiLoad', (e) => { const key = e.detail.key; log.textContent = `Fetching ${e.detail.label}.`; mark(key, { loading: true }); setTimeout(() => { mark(key, { loading: false, children: kids[key] }); log.textContent = `${e.detail.label} loaded.`; }, 900); }); }

Moving rows

drag-drop lets a row move by pointer or keyboard. Dropping on the top or bottom quarter of a row puts the node beside it; dropping on the middle makes it the last child of that row. Ctrl and the arrow keys do the same three moves, and every move writes nodes back and emits suiNodesChange.

Nothing moved yet.
<div style="display:grid;gap:10px;max-width:380px"> <code id="tr-move-log" style="font:460 12px/1.6 var(--font-sans);color:var(--ink-muted)">Nothing moved yet.</code> <sui-tree id="tr-move" drag-drop selection-mode="single"></sui-tree> </div>
// setup(root): properties and events setup(root) { const t = root.querySelector('#tr-move'); const log = root.querySelector('#tr-move-log'); t.nodes = accounts(); t.expanded = branches(); t.addEventListener('suiNodesChange', (e) => { log.textContent = `Top level: ${e.detail.map((n) => n.label).join(', ')}.`; }); }

Events

expanded and selection are written before suiExpandedChange and suiSelectionChange fire, and each event carries the whole key array, numbers kept as numbers. Neither bubbles, so listen on the tree itself. Writing expanded from outside, as the buttons do, opens the branches without an event.

Nothing yet.
<div style="display:grid;gap:10px;max-width:380px"> <code id="tr-log" style="font:460 12px/1.6 var(--font-sans);color:var(--ink-muted)">Nothing yet.</code> <div style="display:flex;gap:8px"> <sui-button id="tr-open" variant="outline" size="sm" label="Expand all"></sui-button> <sui-button id="tr-close" variant="outline" size="sm" label="Collapse all"></sui-button> </div> <sui-tree id="tr-events" selection-mode="single"></sui-tree> </div>
// setup(root): properties and events setup(root) { const t = root.querySelector('#tr-events'); const log = root.querySelector('#tr-log'); t.nodes = accounts(); t.expanded = ['assets']; t.addEventListener('suiExpandedChange', (e) => { log.textContent = `Expanded: ${e.detail.join(', ') || 'none'}.`; }); t.addEventListener('suiSelectionChange', (e) => { log.textContent = `Selected: ${e.detail.join(', ')}.`; }); root.querySelector('#tr-open').addEventListener('click', () => { t.expanded = branches(); }); root.querySelector('#tr-close').addEventListener('click', () => { t.expanded = []; }); }

Keyboard

KeyAction
TabEnters the tree at the active row, else the first. The tree is one tab stop, and in checkbox mode the select-all header is the stop before it.
ArrowDown ArrowUpMoves focus to the next or previous visible row.
ArrowRightOpens a closed branch. On an open branch, moves to its first child. Under dir="rtl" the pair swaps, so the arrow that points along the reading direction is always the forward one.
ArrowLeftCloses an open branch. Otherwise moves to the parent row.
Home EndMoves focus to the first or last visible row.
EnterSelects the row, or toggles the box in checkbox mode. With selection-mode="none", opens or closes a branch.
SpaceSelects the row, or toggles the box in checkbox mode. On the select-all header, ticks every node the filter shows, or clears them.
Ctrl+ArrowUp Ctrl+ArrowDownWith drag-drop, moves the focused row above or below the sibling next to it (Cmd on a Mac).
Ctrl+ArrowRightWith drag-drop, makes the focused row the last child of the row above it.
Ctrl+ArrowLeftWith drag-drop, moves the focused row out of its parent, to sit just after it.

API

<sui-tree>

Properties

PropertyAttributeTypeDefaultDescription
dragDropdrag-dropbooleanfalsePointer drag and the Ctrl arrow keys move a row within the tree. Off by default, since most trees are read, not rearranged; the elements made for moving things (the reorder lists, the board and the calendar) drag by default and turn it off with noDrag. Not draggable, which is a native attribute. Reflected.
emptyFilterMessageempty-filter-messagestringstring | undefinedRow shown when the filter matched nothing. An empty list and a search that found nothing are different states and one sentence cannot be right about both: "Nothing here yet." is wrong over a list of two hundred the reader has just narrowed to none.
emptyMessageempty-messagestringstring | undefinedRow shown when the node list is empty. The empty slot replaces it.
expandedproperty onlySuiKey[][]Keys of expanded branches. Mutable; updated before suiExpandedChange fires. Property, not attribute.
filterfilterbooleanfalseShows the filter box; branches with matching descendants open automatically while filtering. Reflected.
loadingloadingbooleanfalseShows the whole tree is still on its way: the box shows the loading row instead of rows, the filter box and the select-all header. Per-branch waits use the node's own loading. Reflected, so a page can style the wait.
loadingMessageloading-messagestringstring | undefinedText of the loading row.
nodesproperty onlySuiTreeNode[][]Node tree (key, label, icon, meta, children). Rendered as tree > treeitem (with aria-level and aria-expanded) > group. Mutable: a move writes the new tree back and emits suiNodesChange. Property, not attribute.
selectionproperty onlySuiKey[][]Keys of the selected nodes, as every collection holds its selection: one key in single mode, any number in checkbox mode. Mutable; updated before suiSelectionChange fires. Property, not attribute.
selectionModeselection-modeSuiTreeSelectionMode"checkbox" | "none" | "single"'single'none, single (one highlighted row, aria-selected) or checkbox (multi-select boxes, aria-checked, and a select-all header above the tree). Checkbox mode draws the 18px system box itself rather than a sui-checkbox, and the row, not the box, carries the state. Reflected.

Events

EventDetailReactVueDescription
suiExpandedChangeSuiKey[]onSuiExpandedChange@sui-expanded-changeFires when a branch opens or closes; detail is the array of expanded keys. Does not bubble.
suiLoadSuiTreeNodeonSuiLoad@sui-loadAsks the host for a branch's children: fires when the element opens a node marked leaf: false that has none yet and is not already loading. Detail is the node. Answer by setting its loading, then its children, and assigning a new nodes array. Writing expanded from outside opens a branch without asking, since the caller already knows which.
suiNodesChangeSuiTreeNode[]onSuiNodesChange@sui-nodes-changeFires after a row moves; detail is the new nodes tree, which is written back first. Does not bubble.
suiSelectionChangeSuiKey[]onSuiSelectionChange@sui-selection-changeFires when the selection changes; detail is the new selection. Does not bubble.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the tree's single tab stop (the active row, else the first).

Slots

SlotDescription
emptyWhat to show in place of the empty sentence when nodes holds nothing: a line and a button offering the next step. The same slot name the grid, the list and the tree table take; a filter that found nothing keeps its own sentence.

Types

SuiKey typeA record's or an item's key wherever the library identifies one: a row, a tree node, a list item, a selected value. Numbers stay numbers, so a numeric id round-trips through selection, the events and a framework binding without turning into a string.
No members.
SuiTreeNode interface
keySuiKeyIdentity for expanded and selection, kept as given: a numeric id stays a number.
labelstring
icon?stringFont Awesome class drawn before the label.
meta?stringMetadata at the end of the row (a balance, a count); tabular numerals.
children?SuiTreeNode[]
leaf?booleanfalse says the node is a branch whose children have not arrived: it draws the chevron and asks for them with suiLoad the first time it opens. Leave it unset for a settled node.
loading?booleanDraws a spinner where the chevron was and sets aria-busy while the host fetches this branch's children.
SuiTreeSelectionMode type
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

Color25

--accentthe one chart highlight: the flagged series, down candles, the peak cell--accent-inkorange shaded for small text on light--accent-softorange 18% on white, warning fills, highlights--bordercard + table outer borders--border-strongcontrol borders, emphasized rules--brand--brand-contrasttext and glyphs sitting on solid brand fills--brand-soft-ink--control-borderthe resting edge of every field and control: 3:1 on its grounds--danger--disabled-bg--disabled-ink--focus-halo--focus-ring--hairlinerow separators--ink--ink-faintmetadata beside body ink: 4.8:1 on the page grounds (5.4:1 in dark)--ink-mutedsecondary text: 7:1 on the page grounds and 1.5 times faint--ink-muted-on-washthe muted ink a selected, current or pressed row paints: 4.5:1 on that wash--ink-placeholdera field's placeholder, not a text tier: 4.5:1 on the page grounds--surfacecards, tables, inputs--surface-hovera hovered row or quiet control on any ground: the ink, translucent--surface-raisedmenus, popovers, toasts: the card in light, a step above it in dark--surface-selecteda selected row or option: the primary's hue, always heavier than a hover--surface-sunkentable headers, wells

Typography1

Ag--font-sans

Spacing and size5

42--control-lgform-page inputs34--control-mdDEFAULT toolbar/filter controls12--control-pad-x28--control-sm700--z-dropdown

Radius3

--radius-lgcards, tables, panels, dialogs--radius-mdALL controls: inputs, buttons, selects--radius-xscheckboxes, mini swatches

Elevation1

--shadow-popdropdowns, popovers, toasts

Motion4

--duration-basetoggles, chevrons, tabs, disclosure heights--duration-fasthovers, presses, every exit--duration-overlayevery entrance: palette, popover, dialog, drawer, toast, menu, select list--ease-out

Every name above is listed with its value and its role in Foundations.

See also