Tree
Shows a hierarchy people open level by level and pick one item from. For rows with columns, use Data table.
Installation
The CLI installs the Radix or Base UI file to match your project, with what it builds on: spinner.
If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.
When to use
Use it
- People browse a hierarchy and pick one item from it: an account hierarchy, a role or territory hierarchy, report folders, Setup's menu. It replaces
lightning-tree. - The hierarchy is deeper than two levels, or long enough that people need to open and close branches to find their way.
- Items are places to go: give them an
hrefand the tree is a navigation list. - People search the hierarchy by name. Pass the search field's text as
filter. - A branch's children come from a request. Mark it
loadingand add itsitemswhen they arrive.
Don't use it
- Each item has several values to compare in columns. Use Data table.
- The app's main navigation, a flat list of pages with a few groups. Use Sidebar.
- One section that shows or hides more content. Use Collapsible, or Accordion for several.
- People pick a value for a form field. Use Select, or Combobox to filter as they type.
Usage
import { Tree, type TreeItem } from "@/components/wisty/tree"<Tree
aria-label="Account hierarchy"
items={[
{
name: "global-media",
label: "Global Media",
metatext: "Toronto",
items: [{ name: "global-media-us", label: "Global Media US" }],
},
]}
defaultSelected="global-media-us"
/>Items are data, not parts: each has a unique name, which selected, expanded and the callbacks use, and nests its children in items. The selected item's parents open on first render. onExpandedChange gets every open item's name, so the item that just opened is the one not in your last list:
function Libraries() {
const [expanded, setExpanded] = useState<string[]>([])
return (
<Tree
aria-label="Libraries"
items={libraries}
expanded={expanded}
onExpandedChange={(names) => {
const opened = names.filter((name) => !expanded.includes(name))
setExpanded(names)
opened.forEach(loadFolders)
}}
/>
)
}A title above the tree is a TreeHeader the page writes, with its id in the tree's aria-labelledby (the With a header example).
filterTreeItems(items, filter) returns what the tree shows for a filter. When its length is 0, show a no-results message beside the tree, as the Filtering example does.
Examples
With a header
A role hierarchy under a `TreeHeader` title, which names the tree through its id in `aria-labelledby`.
Links
Report folders whose reports have an `href`: a click or Enter opens one, and arrow keys only move the selection.
With icons
A record's related records with an object `icon` before each label, and a lock as the `endIcon` of a private opportunity.
Disabled
Inactive territories set `disabled`: the keyboard skips them, and Latin America can't open.
Filtering
Setup's Quick Find: the search field sets `filter`, the tree keeps the matches and opens the items above them.
Loading children
Libraries set `loading` until their folders arrive: opening one shows a spinner while `onExpandedChange` fetches them.
API reference
| Part | Built on | Description |
|---|---|---|
Tree | <div> (opens in a new tab) | |
TreeHeader | <div> (opens in a new tab) | A title above a tree. Give it an id and pass that to the tree's aria-labelledby. |
Tree
| Prop | Type | Default | Description |
|---|---|---|---|
items* | TreeItem[] | The tree's items, nested through each item's items. | |
selected | string | null | The selected item's name. null selects none. | |
defaultSelected | string | null | null | |
onSelectedChange | (name: string) => void | Called with the item's name when a click or a key selects another item. | |
expanded | string[] | The names of the open items. | |
defaultExpanded | string[] | [] | The names of the open items at first. The selected item's parents open too. |
onExpandedChange | (names: string[]) => void | Called with every open item's name when one opens or closes. | |
filter | string | Shows only the items whose label or metatext contains it, their parents open and the text marked. |
filterTreeItemsfunction
The items a filter leaves: every item whose label or metatext contains it (ignoring case) with all of its children, and the items above each one. An empty filter leaves them all. Check .length for an empty result.
| Parameter | Type | Default |
|---|---|---|
items* | TreeItem[] | |
filter | string |
Returns TreeItem[].
TreeItemtype
| Member | Type | Default | Description |
|---|---|---|---|
name* | string | Unique across the tree: what selection and expansion are keyed by. | |
label* | string | ||
metatext | string | A second line under the label, like a type or a city. | |
href | string | Makes the label a link. It opens on click or Enter, never on arrow keys. | |
disabled | boolean | Can't be focused, selected or opened, and neither can anything under it. | |
icon | React.ReactNode | Before the label. Decorative: put the meaning in the label or metatext. | |
endIcon | React.ReactNode | After the label, at the row's end. Decorative, as icon. | |
loading | boolean | A branch whose children are on their way: it opens to a spinner. | |
items | TreeItem[] | Children. An item without any is a leaf, unless it's loading. |
SLDS mapping
Tree is slds-tree_container around the slds-tree. TreeHeader is slds-tree__group-header, drawn as a title rather than lightning-tree's h4, so it never breaks the page's heading order. Each item is a role="treeitem" around its slds-tree__item row: the chevron (slds-button_icon, slds-hidden on a leaf), then slds-tree__item-label and slds-tree__item-meta in slds-has-flexi-truncate. A label with an href is inside an a. Children sit in a role="group". A filter's matches use SLDS's mark style.
Known gaps:
- The chevron is lucide's, where SLDS draws
utility:chevronright. icon,endIcon,filterandloadinghave no SLDS markup. The icons sit before and after the label with x-small margins, and a loading branch shows SLDS's x-small inline spinner in place of its children.- No width cap. SLDS stops a tree at 25rem; set
max-w-100withclassNameto match. - One item selected at a time. SLDS's multi-select tree isn't covered.
| Prop | Value | SLDS | Notes |
|---|---|---|---|
(an item with items or loading) | branch (added) | slds-tree__item with slds-button_icon (the chevron), [aria-expanded] | A chevron that turns down when the item opens; its children follow in a group. |
(an item without items) | leaf (added) | slds-tree__item with slds-button_icon slds-hidden | The chevron's place stays empty, so labels line up. |
selected | (an item's name) (added) | [role="treeitem"][aria-selected="true"] > .slds-tree__item | The row fills and a bar marks its start edge. |
metatext | (text) (added) | slds-tree__item-meta | A gray second line under the label. |
href | (a URL) (added) | slds-tree__item-label inside an a | The label takes the link color. |
disabled | true (added) | [aria-disabled="true"] | No hover, and the meta text grays out. The item and everything under it are skipped by the keyboard. |
loading | true (added) | slds-spinner_x-small in the item's group | A branch that opens to a spinner until its items arrive. |
TreeHeader | (part) (added) | slds-tree__group-header | A title above the tree. The page gives it an id and passes it to the tree's aria-labelledby. |
filter | (text) (added) | mark | Shows the matching items and the items above them, the match marked. |
Keyboard interactions
| Key | Action |
|---|---|
| Down arrow | Moves focus and selection to the next item, skipping disabled ones. |
| Up arrow | Moves focus and selection to the previous item. |
| Right arrow | Opens a closed branch. On an open branch, moves to its first child. Does nothing on a leaf. |
| Left arrow | Closes an open branch. Otherwise moves to the parent. |
| Home | Moves to the first item. |
| End | Moves to the last item that's showing. |
| Enter | Selects the item, and opens its link if it has one. |
| Space | Selects the item. |
| * | Opens every branch beside the focused item. |
| Letters | Moves to the next item whose label starts with the typed letters. |
The tree is one tab stop, on the selected item. When that item is inside a closed branch, the tab stop is the closed branch.
Accessibility
- Name the tree with
aria-label, or witharia-labelledbypointing at aTreeHeaderor a heading of the page's own. - Selection follows focus, so
onSelectedChangeruns on every arrow key. Don't navigate or load a page from it; give items anhrefinstead, which opens only on a click or Enter. - Each item is read as its label, then its metatext. Icons are hidden from screen readers: put what an icon means in the label or the metatext.
- A disabled item and everything under it are skipped by the keyboard and can't be selected or opened.
Motion
- The chevron turns over 0.2s when a branch opens or closes. With reduced motion it turns at once.
Performance
- Every open item renders. For thousands of items, keep branches closed and load their children when they open.
Rules
- Give every item a
namethat's unique in the whole tree, not just among its siblings. - Keep items' meaning in
labelandmetatext. They're the item's name for screen readers, and whatfiltersearches. - Pass a new
itemsarray to change the tree, as React state. A loading branch keepsloadinguntil itsitemsare set. - Never pass
asChildorrender.
Related
- SidebarLays out an app's side navigation, with groups, a header and a footer.
- CollapsibleShows and hides a block of content from a trigger, like a record's details.
- AccordionStacks titled sections that expand to show content, one or several at a time.
- Data tableShows records in a table that sorts, selects, runs row actions and loads more.