Skip to content

Tree

Shows a hierarchy people open level by level and pick one item from. For rows with columns, use Data table.

United Oil & Gas Corp.New York, NY
United Oil & Gas, UKLondon, UK
United Oil & Gas, AberdeenAberdeen, UK
United Oil & Gas, PembrokePembroke, UK
United Oil & Gas, HoustonHouston, TX

Installation

pnpm dlx shadcn@latest add @wisty/tree

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 href and 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 loading and add its items when 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`.

Role hierarchy
CEO
SVP, Sales & Marketing
VP, International Sales
VP, Marketing
CFO
COO

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.

Edge CommunicationsAustin, TX
Edge Emergency GeneratorClosed Won
Edge InstallationProposal/Price Quote, private
Rose GonzalezSVP, Procurement
Electrical wiring installationCase 00001002

Disabled

Inactive territories set `disabled`: the keyboard skips them, and Latin America can't open.

North America
US West12 accounts
US East18 accounts
CanadaInactive

Filtering

Setup's Quick Find: the search field sets `filter`, the tree keeps the matches and opens the items above them.

Administration

Loading children

Libraries set `loading` until their folders arrive: opening one shows a spinner while `onExpandedChange` fetches them.

API reference

PartBuilt onDescription
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

PropTypeDefaultDescription
items*TreeItem[]The tree's items, nested through each item's items.
selectedstring | nullThe selected item's name. null selects none.
defaultSelectedstring | nullnull
onSelectedChange(name: string) => voidCalled with the item's name when a click or a key selects another item.
expandedstring[]The names of the open items.
defaultExpandedstring[][]The names of the open items at first. The selected item's parents open too.
onExpandedChange(names: string[]) => voidCalled with every open item's name when one opens or closes.
filterstringShows 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.

ParameterTypeDefault
items*TreeItem[]
filterstring

Returns TreeItem[].

TreeItemtype

MemberTypeDefaultDescription
name*stringUnique across the tree: what selection and expansion are keyed by.
label*string
metatextstringA second line under the label, like a type or a city.
hrefstringMakes the label a link. It opens on click or Enter, never on arrow keys.
disabledbooleanCan't be focused, selected or opened, and neither can anything under it.
iconReact.ReactNodeBefore the label. Decorative: put the meaning in the label or metatext.
endIconReact.ReactNodeAfter the label, at the row's end. Decorative, as icon.
loadingbooleanA branch whose children are on their way: it opens to a spinner.
itemsTreeItem[]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, filter and loading have 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-100 with className to match.
  • One item selected at a time. SLDS's multi-select tree isn't covered.
PropValueSLDSNotes
(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-hiddenThe chevron's place stays empty, so labels line up.
selected(an item's name) (added)[role="treeitem"][aria-selected="true"] > .slds-tree__itemThe row fills and a bar marks its start edge.
metatext(text) (added)slds-tree__item-metaA gray second line under the label.
href(a URL) (added)slds-tree__item-label inside an aThe label takes the link color.
disabledtrue (added)[aria-disabled="true"]No hover, and the meta text grays out. The item and everything under it are skipped by the keyboard.
loadingtrue (added)slds-spinner_x-small in the item's groupA branch that opens to a spinner until its items arrive.
TreeHeader(part) (added)slds-tree__group-headerA title above the tree. The page gives it an id and passes it to the tree's aria-labelledby.
filter(text) (added)markShows 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 with aria-labelledby pointing at a TreeHeader or a heading of the page's own.
  • Selection follows focus, so onSelectedChange runs on every arrow key. Don't navigate or load a page from it; give items an href instead, 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 name that's unique in the whole tree, not just among its siblings.
  • Keep items' meaning in label and metatext. They're the item's name for screen readers, and what filter searches.
  • Pass a new items array to change the tree, as React state. A loading branch keeps loading until its items are set.
  • Never pass asChild or render.