HextaUI

BetaBuilt on Vercel’s UI SDK 0.1.0-beta.1. Its API may change.

Tree

A tree view on Vercel's UI SDK for files and nested pages, with file paths, checkboxes, links and children that load on demand.

Depends on
  • @tabler/icons-react
  • @ui-sdk/core
  • @ui-sdk/react
  • class-variance-authority
components
ui
button.tsx
tree.tsx
theme.tsx
package.json
README.md
import {
  IconBrandTypescript,
  IconFileText,
  IconFolder,
  IconFolderOpen,
} from "@tabler/icons-react"

import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemIndicator,
  TreeItemLabel,
  type TreeItemData,
} from "@/components/ui/ui-sdk-tree"

const files: TreeItemData[] = [
  {
    value: "app",
    label: "app",
    children: [
      { value: "app/layout.tsx", label: "layout.tsx" },
      { value: "app/page.tsx", label: "page.tsx" },
      { value: "app/globals.css", label: "globals.css" },
    ],
  },
  {
    value: "components",
    label: "components",
    children: [
      {
        value: "components/ui",
        label: "ui",
        children: [
          { value: "components/ui/button.tsx", label: "button.tsx" },
          { value: "components/ui/tree.tsx", label: "tree.tsx" },
        ],
      },
      { value: "components/theme.tsx", label: "theme.tsx" },
    ],
  },
  { value: "package.json", label: "package.json" },
  { value: "README.md", label: "README.md" },
]

function Items({ items }: { items: TreeItemData[] }) {
  return items.map((item) => (
    <TreeItem key={item.value} value={item.value}>
      <TreeItemLabel>
        {item.children ? (
          <>
            <TreeItemIndicator />
            <IconFolder className="group-data-expanded/tree-label:hidden" />
            <IconFolderOpen className="not-group-data-expanded/tree-label:hidden" />
          </>
        ) : item.label.endsWith(".tsx") ? (
          <IconBrandTypescript />
        ) : (
          <IconFileText />
        )}
        <span>{item.label}</span>
      </TreeItemLabel>
      {item.children && (
        <TreeGroup>
          <Items items={item.children} />
        </TreeGroup>
      )}
    </TreeItem>
  ))
}

function UiSdkTreeDemo() {
  return (
    <Tree
      aria-label="Project files"
      items={files}
      defaultExpanded={["components", "components/ui"]}
      defaultValue="components/ui/tree.tsx"
      className="max-w-xs"
    >
      <Items items={files} />
    </Tree>
  )
}

export { UiSdkTreeDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-tree.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

UI SDK adds a little CSS of its own in a cascade layer named ui-sdk. Declare that layer at the top of your global stylesheet, above the Tailwind import, so your classes win over it.

@layer ui-sdk;

@import "tailwindcss";
import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemIndicator,
  TreeItemLabel,
} from "@/components/ui/ui-sdk-tree"
<Tree aria-label="Project files">
  <TreeItem value="src">
    <TreeItemLabel>
      <TreeItemIndicator />
      <span>src</span>
    </TreeItemLabel>
    <TreeGroup>
      <TreeItem value="src/index.ts">
        <TreeItemLabel>
          <span>index.ts</span>
        </TreeItemLabel>
      </TreeItem>
    </TreeGroup>
  </TreeItem>
</Tree>

An item with a TreeGroup inside it is a branch. Give every item a unique value and the tree an accessible name. Focus stays on the item, and its label is the row you see, so selected, current and focused styles all land on the label.

Tree
└── TreeItem
    ├── TreeItemLabel
    │   ├── TreeItemIndicator
    │   └── TreeItemCheckbox
    └── TreeGroup
        └── TreeItem

Pass items and no children, and the tree renders styled rows with chevrons for you. Each item has a value, a label and optional children.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "fruit",
    label: "Fruit",
    children: [
      { value: "apple", label: "Apple" },
      { value: "banana", label: "Banana" },
      { value: "cherry", label: "Cherry" },
    ],
  },
  {
    value: "vegetables",
    label: "Vegetables",
    children: [
      { value: "carrot", label: "Carrot" },
      { value: "leek", label: "Leek" },
    ],
  },
  { value: "bread", label: "Bread" },
]

function UiSdkTreeData() {
  return (
    <Tree
      aria-label="Groceries"
      items={items}
      defaultExpanded={["fruit"]}
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeData }

Pass paths for a file explorer, with folders first. With activation="automatic" the arrow keys select, and an onAction that returns a promise marks the row pending until it settles.

"use client"

import * as React from "react"
import { IconLoader2 } from "@tabler/icons-react"

import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemIndicator,
  TreeItemLabel,
  treeFromPaths,
  type TreeItemData,
} from "@/components/ui/ui-sdk-tree"

const paths = [
  "src/index.ts",
  "src/lib/format.ts",
  "src/lib/parse.ts",
  "src/routes/home.tsx",
  "src/routes/about.tsx",
  "tests/",
  "package.json",
  "tsconfig.json",
]

const items = treeFromPaths(paths)
const files = new Set(paths.filter((path) => !path.endsWith("/")))

function Items({ items }: { items: TreeItemData[] }) {
  return items.map((item) => (
    <TreeItem key={item.value} value={item.value}>
      <TreeItemLabel>
        {(state) => (
          <>
            {item.children && <TreeItemIndicator />}
            <span>{item.label}</span>
            {state.pending && (
              <IconLoader2 className="ms-auto motion-safe:animate-spin" />
            )}
          </>
        )}
      </TreeItemLabel>
      {item.children && item.children.length > 0 && (
        <TreeGroup>
          <Items items={item.children} />
        </TreeGroup>
      )}
    </TreeItem>
  ))
}

function UiSdkTreePaths() {
  const [opened, setOpened] = React.useState<string | null>(null)

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Tree
        aria-label="Project"
        paths={paths}
        defaultExpanded={["src"]}
        defaultValue="src/index.ts"
        activation="automatic"
        onAction={async (value) => {
          if (!files.has(value)) {
            return
          }
          await new Promise((resolve) => setTimeout(resolve, 600))
          setOpened(value)
        }}
      >
        <Items items={items} />
      </Tree>
      <p className="text-sm text-muted-foreground" aria-live="polite">
        {opened ? `Opened ${opened}` : "Press Enter to open a file."}
      </p>
    </div>
  )
}

export { UiSdkTreePaths }

With mode="multiple", Cmd or Ctrl-click toggles an item and Shift extends a range. expandOnPress={false} leaves expanding to the chevron and the arrow keys.

"use client"

import * as React from "react"

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "inbox",
    label: "Inbox",
    children: [
      { value: "updates", label: "Updates" },
      { value: "forums", label: "Forums" },
      { value: "promotions", label: "Promotions" },
    ],
  },
  {
    value: "projects",
    label: "Projects",
    children: [
      { value: "website", label: "Website" },
      { value: "mobile", label: "Mobile app" },
    ],
  },
  { value: "archive", label: "Archive" },
  { value: "trash", label: "Trash" },
]

function UiSdkTreeMultiple() {
  const [value, setValue] = React.useState(["updates", "website"])

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Tree
        aria-label="Folders"
        items={items}
        mode="multiple"
        value={value}
        onValueChange={setValue}
        expandOnPress={false}
        defaultExpanded={["inbox", "projects"]}
      />
      <p className="text-sm text-muted-foreground">{value.length} selected</p>
    </div>
  )
}

export { UiSdkTreeMultiple }

Add checkable and a TreeItemCheckbox in each label. Checking a branch checks everything in it, and a partly checked branch shows a dash.

"use client"

import * as React from "react"

import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemCheckbox,
  TreeItemIndicator,
  TreeItemLabel,
  type TreeItemData,
} from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "frontend",
    label: "Frontend",
    children: [
      { value: "react", label: "React" },
      { value: "vue", label: "Vue" },
      { value: "svelte", label: "Svelte" },
    ],
  },
  {
    value: "backend",
    label: "Backend",
    children: [
      { value: "node", label: "Node" },
      {
        value: "databases",
        label: "Databases",
        children: [
          { value: "postgres", label: "Postgres" },
          { value: "sqlite", label: "SQLite" },
        ],
      },
    ],
  },
]

function Items({ items }: { items: TreeItemData[] }) {
  return items.map((item) => (
    <TreeItem key={item.value} value={item.value}>
      <TreeItemLabel>
        {item.children && <TreeItemIndicator />}
        <TreeItemCheckbox />
        <span>{item.label}</span>
      </TreeItemLabel>
      {item.children && (
        <TreeGroup>
          <Items items={item.children} />
        </TreeGroup>
      )}
    </TreeItem>
  ))
}

function UiSdkTreeCheckboxes() {
  const [checked, setChecked] = React.useState(["react", "postgres"])

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Tree
        aria-label="Stack"
        items={items}
        mode="none"
        checkable
        checked={checked}
        onCheckedChange={setChecked}
        defaultExpanded={["frontend", "backend", "databases"]}
      >
        <Items items={items} />
      </Tree>
      <p className="text-sm text-muted-foreground">
        {checked.length > 0 ? checked.join(", ") : "Nothing checked"}
      </p>
    </div>
  )
}

export { UiSdkTreeCheckboxes }

Items with an href render as links. With mode="none" and current, the current page gets aria-current, its parents open and it takes the tab stop.

"use client"

import * as React from "react"

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const pages: TreeItemData[] = [
  {
    value: "getting-started",
    label: "Getting started",
    children: [
      { value: "introduction", label: "Introduction", href: "#introduction" },
      { value: "installation", label: "Installation", href: "#installation" },
    ],
  },
  {
    value: "components",
    label: "Components",
    children: [
      { value: "accordion", label: "Accordion", href: "#accordion" },
      { value: "dialog", label: "Dialog", href: "#dialog" },
      { value: "tree", label: "Tree", href: "#tree" },
    ],
  },
  { value: "changelog", label: "Changelog", href: "#changelog" },
]

function UiSdkTreeLinks() {
  const [current, setCurrent] = React.useState("tree")

  return (
    <Tree
      aria-label="Docs"
      items={pages}
      mode="none"
      current={current}
      onAction={(value, { event }) => {
        if (value === "getting-started" || value === "components") {
          return
        }
        event?.preventDefault()
        setCurrent(value)
      }}
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeLinks }

Mark an item branch before its children exist, and loadChildren runs the first time it opens. The chevron turns into a spinner while it loads, and into a retry icon if it fails. Archive fails once.

"use client"

import * as React from "react"

import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemIndicator,
  TreeItemLabel,
} from "@/components/ui/ui-sdk-tree"

type Row = { value: string; label: string; branch?: boolean }

const top: Row[] = [
  { value: "users", label: "users", branch: true },
  { value: "orders", label: "orders", branch: true },
  { value: "archive", label: "archive", branch: true },
]

async function fetchRows(value: string, signal: AbortSignal, fail: boolean) {
  await new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, 800)
    signal.addEventListener("abort", () => {
      clearTimeout(timer)
      reject(signal.reason)
    })
  })
  if (fail) {
    throw new Error("Network error")
  }
  return Array.from({ length: 3 }, (_, index) => ({
    value: `${value}/${index + 1}`,
    label: `${value}_${index + 1}.json`,
  }))
}

function Items({
  items,
  loaded,
}: {
  items: Row[]
  loaded: Record<string, Row[]>
}) {
  return items.map((item) => (
    <TreeItem key={item.value} value={item.value} branch={item.branch}>
      <TreeItemLabel>
        {item.branch && <TreeItemIndicator />}
        <span>{item.label}</span>
      </TreeItemLabel>
      {loaded[item.value] && (
        <TreeGroup>
          <Items items={loaded[item.value]} loaded={loaded} />
        </TreeGroup>
      )}
    </TreeItem>
  ))
}

function UiSdkTreeLazy() {
  const [loaded, setLoaded] = React.useState<Record<string, Row[]>>({})
  const failed = React.useRef(false)

  return (
    <Tree
      aria-label="Database"
      loadChildren={async (value, { signal }) => {
        const fail = value === "archive" && !failed.current
        failed.current ||= fail
        const rows = await fetchRows(value, signal, fail)
        setLoaded((current) => ({ ...current, [value]: rows }))
      }}
      className="max-w-xs"
    >
      <Items items={top} loaded={loaded} />
    </Tree>
  )
}

export { UiSdkTreeLazy }

variant="lines" draws an indent guide for each open group, and size="sm" makes the rows shorter.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "src",
    label: "src",
    children: [
      {
        value: "src/components",
        label: "components",
        children: [
          { value: "src/components/header.tsx", label: "header.tsx" },
          { value: "src/components/footer.tsx", label: "footer.tsx" },
        ],
      },
      { value: "src/main.tsx", label: "main.tsx" },
    ],
  },
  { value: "vite.config.ts", label: "vite.config.ts" },
]

function UiSdkTreeLines() {
  return (
    <Tree
      aria-label="Source"
      items={items}
      variant="lines"
      size="sm"
      defaultExpanded={["src", "src/components"]}
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeLines }

A controller from createTreeController() expands, collapses, selects and reveals items from anywhere.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  createTreeController,
  Tree,
  type TreeItemData,
} from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "design",
    label: "Design",
    children: [
      { value: "tokens", label: "Tokens" },
      {
        value: "icons",
        label: "Icons",
        children: [
          { value: "outline", label: "Outline" },
          { value: "filled", label: "Filled" },
        ],
      },
    ],
  },
  {
    value: "engineering",
    label: "Engineering",
    children: [
      { value: "api", label: "API" },
      { value: "web", label: "Web" },
    ],
  },
]

function UiSdkTreeController() {
  const [tree] = React.useState(createTreeController)

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <div className="flex gap-2">
        <Button variant="outline" size="sm" onClick={() => tree.expandAll()}>
          Expand all
        </Button>
        <Button variant="outline" size="sm" onClick={() => tree.collapseAll()}>
          Collapse all
        </Button>
        <Button variant="ghost" size="sm" onClick={() => tree.reveal("filled")}>
          Reveal Filled
        </Button>
      </div>
      <Tree aria-label="Teams" items={items} controller={tree} />
    </div>
  )
}

export { UiSdkTreeController }

With hiddenUntilFound, closed groups stay in the page, so the browser’s Find in Page can match Phobos and open Mars to show it.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "planets",
    label: "Planets",
    children: [
      { value: "mercury", label: "Mercury" },
      { value: "venus", label: "Venus" },
      {
        value: "earth",
        label: "Earth",
        children: [{ value: "moon", label: "The Moon" }],
      },
      {
        value: "mars",
        label: "Mars",
        children: [
          { value: "phobos", label: "Phobos" },
          { value: "deimos", label: "Deimos" },
        ],
      },
    ],
  },
  {
    value: "dwarf-planets",
    label: "Dwarf planets",
    children: [
      { value: "ceres", label: "Ceres" },
      {
        value: "pluto",
        label: "Pluto",
        children: [{ value: "charon", label: "Charon" }],
      },
    ],
  },
]

function UiSdkTreeFindInPage() {
  return (
    <Tree
      aria-label="Solar system"
      items={items}
      hiddenUntilFound
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeFindInPage }

A disabled item can still take focus, so screen readers find it, but it can’t be selected, checked or opened.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "workspace",
    label: "Workspace",
    children: [
      { value: "general", label: "General" },
      { value: "billing", label: "Billing", disabled: true },
      { value: "members", label: "Members" },
    ],
  },
  { value: "danger", label: "Danger zone", disabled: true },
]

function UiSdkTreeDisabled() {
  return (
    <Tree
      aria-label="Settings"
      items={items}
      defaultExpanded={["workspace"]}
      defaultValue="general"
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeDisabled }

Rows indent from the right, the chevron points left, and the Left and Right arrow keys swap.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "documents",
    label: "المستندات",
    children: [
      { value: "reports", label: "التقارير" },
      { value: "invoices", label: "الفواتير" },
    ],
  },
  {
    value: "photos",
    label: "الصور",
    children: [{ value: "trips", label: "الرحلات" }],
  },
  { value: "notes", label: "ملاحظات" },
]

function UiSdkTreeRtl() {
  return (
    <div dir="rtl" className="w-full max-w-xs">
      <Tree
        aria-label="الملفات"
        items={items}
        defaultExpanded={["documents"]}
        defaultValue="reports"
      />
    </div>
  )
}

export { UiSdkTreeRtl }
KeyAction
↓↑Moves to the next or previous visible item.
→Opens a branch, or moves to its first child.
←Closes a branch, or moves to its parent.
HomeEndMoves to the first or last visible item.
Alt→Opens a branch and everything inside it.
*Opens every branch beside the focused one.
EnterSelects the item and runs its action, or follows its link.
SpaceChecks the item when the tree is checkable, otherwise selects it.
Shift↓Extends a multiple selection.
⌘ASelects every visible item in multiple mode.

The tree follows the WAI-ARIA tree view pattern with one tab stop. Tab lands on the current page, the selected item or the first item. Typing a letter moves to the next item that starts with it. While a slow onAction runs, screen readers hear pendingAnnouncement after a second, so set it in your page’s language.

Groups open and close by height only when motion is allowed. On touch screens rows are taller, and in forced colors mode the selected and current rows get an outline.

Each part wraps the UI SDK part of the same name and accepts all of its props, including render and a className that can be a function of the part’s state.

PropTypeDefault
itemsThe whole hierarchy. Without children, the tree renders styled rows from it.
TreeItemData[]–
pathsFile paths. Folders become branches; a path ending in / is an empty folder.
string[]–
mode
"single" | "multiple" | "none""single"
value
string | string[] | null–
defaultValue
string | string[] | null–
onValueChange
(value, details) => void–
activation
"automatic" | "manual""manual"
expanded
string[]–
defaultExpanded
string[]–
onExpandedChange
(expanded, details) => void–
expandOnPress
booleantrue
checkable
booleanfalse
checked
string[]–
defaultChecked
string[]–
onCheckedChange
(checked, details) => void–
checkStrategy
"leaf" | "trunk" | "branch" | "independent""leaf"
onActionRuns on press or Enter. Return a promise to mark the item pending.
(value, details) => unknown–
pendingAnnouncement
string–
loadChildren
(value, { signal }) => Promise<unknown>–
current
string | null–
disabled
booleanfalse
hiddenUntilFound
booleanfalse
keepMounted
booleanfalse
variant
"default" | "lines""default"
size
"default" | "sm""default"
controllerFrom createTreeController().
TreeController–
AttributeDescription
data-slot="tree"Target the tree in CSS.
data-variantThe variant: default or lines.
data-sizeThe size: default or sm.
--tree-row-heightThe height of each row, set by size.
--tree-indentHow far each level indents.
PropTypeDefault
valueUnique in the tree.
string–
branchSet it for a branch whose children load later.
boolean–
disabled
booleanfalse
labelThe text typeahead matches, when the label isn’t plain text.
string–
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="tree-item"Target an item in CSS. It holds focus.
data-levelThe depth, from 1.
data-branchPresent on a branch.
data-expandedPresent while a branch is open.
--tree-levelThe depth, for indenting a flat list.
PropTypeDefault
render
ReactElement | (props, state) => ReactElement–
AttributeDescription
data-slot="tree-label"Target a row in CSS.
data-selectedPresent while the item is selected.
data-currentPresent on the current page’s item.
data-expandedPresent while a branch is open.
data-pendingPresent while the item’s onAction runs.
data-disabledPresent when the item is disabled.
PropTypeDefault
childrenDefaults to a chevron, a spinner while loading and a retry icon after a failure.
ReactNode | (state) => ReactNode–
AttributeDescription
data-slot="tree-indicator"Target the chevron in CSS.
data-loadingPresent while children load.
data-failedPresent after children failed to load.
PropTypeDefault
childrenDefaults to a check, or a dash when mixed.
ReactNode | (state) => ReactNode–
AttributeDescription
data-slot="tree-checkbox"Target the checkbox in CSS.
data-checkedPresent while checked.
data-indeterminatePresent while a branch is partly checked.
PropTypeDefault
keepMounted
boolean–
hiddenUntilFound
boolean–
AttributeDescription
data-slot="tree-group"Target a group in CSS.
data-openPresent while open.
--tree-group-heightThe group’s height while it animates.