# Tree

> Nested, expandable rows for files and hierarchies, with indent guides, connector lines, cascading checkboxes, typeahead and full keyboard support.

Docs: https://hextaui.com/docs/tree
Markdown: https://hextaui.com/docs/tree.md

```tsx title="components/examples/tree/demo.tsx"
import {
  IconBrandTypescript,
  IconFileText,
  IconFolder,
  IconFolderOpen,
  IconPhoto,
} from "@tabler/icons-react"

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

function Folder({
  value,
  name,
  children,
}: {
  value: string
  name: string
  children: React.ReactNode
}) {
  return (
    <TreeItem value={value}>
      <TreeItemLabel icon={<IconFolder />} expandedIcon={<IconFolderOpen />}>
        {name}
      </TreeItemLabel>
      <TreeGroup>{children}</TreeGroup>
    </TreeItem>
  )
}

function File({
  value,
  name,
  icon = <IconBrandTypescript />,
  status,
}: {
  value: string
  name: string
  icon?: React.ReactNode
  status?: "M" | "U"
}) {
  return (
    <TreeItem value={value}>
      <TreeItemLabel
        icon={icon}
        meta={
          status ? (
            <span
              className={status === "M" ? "text-warning" : "text-success"}
              aria-label={status === "M" ? "Modified" : "Untracked"}
            >
              {status}
            </span>
          ) : null
        }
      >
        {name}
      </TreeItemLabel>
    </TreeItem>
  )
}

export function TreeDemo() {
  return (
    <Tree
      aria-label="Project files"
      defaultExpandedValues={["app", "components", "ui"]}
      defaultSelectedValues={["button"]}
      className="w-full max-w-xs"
    >
      <Folder value="app" name="app">
        <File value="layout" name="layout.tsx" />
        <File value="page" name="page.tsx" status="M" />
        <File value="globals" name="globals.css" icon={<IconFileText />} />
      </Folder>
      <Folder value="components" name="components">
        <Folder value="ui" name="ui">
          <File value="button" name="button.tsx" status="M" />
          <File value="tree" name="tree.tsx" status="U" />
        </Folder>
        <File value="theme" name="theme-provider.tsx" />
      </Folder>
      <Folder value="public" name="public">
        <File value="og" name="og.png" icon={<IconPhoto />} />
      </Folder>
      <File value="readme" name="README.md" icon={<IconFileText />} />
    </Tree>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/tree.json
```

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

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/tree.tsx"
"use client"

import * as React from "react"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { IconChevronRight } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Checkbox } from "@/components/ui/checkbox"

const treeVariants = cva(
  "relative flex w-full min-w-0 flex-col text-sm text-foreground [--tree-chevron:--spacing(4)] [--tree-indent:calc(var(--tree-chevron)+var(--tree-gap))]",
  {
    variants: {
      variant: {
        default: "",
        lines: "",
        connectors: "",
      },
      size: {
        default:
          "[--tree-gap:--spacing(1.5)] [--tree-row-height:--spacing(8)] [--tree-row-px:--spacing(2)] pointer-coarse:[--tree-row-height:--spacing(10)]",
        sm: "[--tree-gap:--spacing(1)] [--tree-row-height:--spacing(7)] [--tree-row-px:--spacing(1.5)] pointer-coarse:[--tree-row-height:--spacing(9)]",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

const treeItemVariants = cva(
  "relative flex min-w-0 flex-col [&:has(>[data-slot=tree-group])>[data-slot=tree-item-label]>[data-slot=tree-item-indicator]>svg]:visible",
  {
    variants: {
      variant: {
        default: "",
        lines: "",
        connectors:
          "in-data-[slot=tree-group-inner]:before:pointer-events-none in-data-[slot=tree-group-inner]:before:absolute in-data-[slot=tree-group-inner]:before:inset-y-0 in-data-[slot=tree-group-inner]:before:start-[calc(var(--tree-row-px)+var(--tree-chevron)/2-var(--tree-indent))] in-data-[slot=tree-group-inner]:before:border-s in-data-[slot=tree-group-inner]:before:border-border in-data-[slot=tree-group-inner]:after:pointer-events-none in-data-[slot=tree-group-inner]:after:absolute in-data-[slot=tree-group-inner]:after:start-[calc(var(--tree-row-px)+var(--tree-chevron)/2-var(--tree-indent))] in-data-[slot=tree-group-inner]:after:top-0 in-data-[slot=tree-group-inner]:after:h-[calc(var(--tree-row-height)/2)] in-data-[slot=tree-group-inner]:after:w-[calc(var(--tree-indent)-var(--tree-chevron)/2+var(--tree-chevron)+var(--tree-gap)-var(--spacing))] in-data-[slot=tree-group-inner]:after:rounded-es-sm in-data-[slot=tree-group-inner]:after:border-s in-data-[slot=tree-group-inner]:after:border-b in-data-[slot=tree-group-inner]:after:border-border in-data-[slot=tree-group-inner]:last:before:hidden in-data-[slot=tree-group-inner]:has-[>[data-slot=tree-group]]:after:w-[calc(var(--tree-indent)-var(--tree-chevron)/2+var(--spacing)*0.5)]",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

const treeGroupVariants = cva(
  "relative flex min-w-0 flex-col ps-(--tree-indent)",
  {
    variants: {
      variant: {
        default: "",
        lines:
          "before:pointer-events-none before:absolute before:inset-y-0 before:start-[calc(var(--tree-row-px)+var(--tree-chevron)/2)] before:border-s before:border-border before:transition-[border-color] before:duration-150 has-[>[data-slot=tree-item]>[data-slot=tree-item-label]:focus-visible]:before:border-muted-foreground/60 has-[>[data-slot=tree-item]>[data-slot=tree-item-label][data-selected]]:before:border-muted-foreground/60",
        connectors: "",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

type TreeVariant = NonNullable<VariantProps<typeof treeVariants>["variant"]>
type TreeSize = NonNullable<VariantProps<typeof treeVariants>["size"]>
type TreeSelectionMode = "none" | "single" | "multiple"
type TreeCheckState = "checked" | "unchecked" | "mixed"

type TreeNode = {
  parent: string | null
  disabled: boolean
  onExpandedChange: (expanded: boolean) => void
}

type TreeRegistry = ReturnType<typeof createTreeRegistry>

function createTreeRegistry() {
  const nodes = new Map<string, TreeNode>()
  const listeners = new Set<() => void>()
  let version = 0

  const emit = () => {
    version += 1
    for (const listener of listeners) {
      listener()
    }
  }

  return {
    nodes,
    register(value: string, node: TreeNode) {
      nodes.set(value, node)
      emit()
      return () => {
        if (nodes.get(value) === node) {
          nodes.delete(value)
          emit()
        }
      }
    },
    subscribe(listener: () => void) {
      listeners.add(listener)
      return () => {
        listeners.delete(listener)
      }
    },
    getVersion: () => version,
  }
}

function getChildren(nodes: Map<string, TreeNode>) {
  const children = new Map<string | null, string[]>()
  for (const [value, node] of nodes) {
    const parent =
      node.parent !== null && nodes.has(node.parent) ? node.parent : null
    const list = children.get(parent)
    if (list) {
      list.push(value)
    } else {
      children.set(parent, [value])
    }
  }
  return children
}

function getCheckStates(
  children: Map<string | null, string[]>,
  checked: Set<string>
) {
  const states = new Map<string, TreeCheckState>()

  const visit = (value: string, inherited: boolean): TreeCheckState => {
    states.set(value, "unchecked")
    const self = inherited || checked.has(value)
    const kids = children.get(value)
    let state: TreeCheckState = self ? "checked" : "unchecked"
    if (kids?.length) {
      let all = true
      let none = true
      for (const kid of kids) {
        const kidState = visit(kid, self)
        if (kidState !== "checked") {
          all = false
        }
        if (kidState !== "unchecked") {
          none = false
        }
      }
      state = all ? "checked" : none ? "unchecked" : "mixed"
    }
    states.set(value, state)
    return state
  }

  for (const root of children.get(null) ?? []) {
    visit(root, false)
  }
  return states
}

function toggleChecked(
  value: string,
  nodes: Map<string, TreeNode>,
  children: Map<string | null, string[]>,
  states: Map<string, TreeCheckState>
) {
  const isLeaf = (item: string) => !children.get(item)?.length
  const leaves: string[] = []
  const collect = (item: string) => {
    if (nodes.get(item)?.disabled) {
      return
    }
    if (isLeaf(item)) {
      leaves.push(item)
    }
    for (const kid of children.get(item) ?? []) {
      collect(kid)
    }
  }
  collect(value)

  if (leaves.length === 0) {
    return null
  }

  const target = leaves.some((leaf) => states.get(leaf) !== "checked")
  const next = new Set<string>()
  for (const [item, state] of states) {
    if (state === "checked" && isLeaf(item)) {
      next.add(item)
    }
  }
  for (const leaf of leaves) {
    if (target) {
      next.add(leaf)
    } else {
      next.delete(leaf)
    }
  }

  const result: string[] = []
  for (const [item, state] of getCheckStates(children, next)) {
    if (state === "checked") {
      result.push(item)
    }
  }
  return result
}

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

function useLatest<T>(value: T) {
  const ref = React.useRef(value)
  React.useLayoutEffect(() => {
    ref.current = value
  })
  return ref
}

function useControllableValues(
  value: string[] | undefined,
  defaultValue: string[] | undefined,
  onChange: ((values: string[]) => void) | undefined
) {
  const [internal, setInternal] = React.useState<string[]>(
    () => defaultValue ?? []
  )
  const controlled = value !== undefined
  const current = controlled ? value : internal
  const latest = React.useRef(current)
  const onChangeRef = useLatest(onChange)

  React.useLayoutEffect(() => {
    latest.current = current
  }, [current])

  const set = React.useCallback(
    (update: (previous: string[]) => string[]) => {
      const previous = latest.current
      const next = update(previous)
      if (
        next.length === previous.length &&
        next.every((item, index) => item === previous[index])
      ) {
        return
      }
      latest.current = next
      if (!controlled) {
        setInternal(next)
      }
      onChangeRef.current?.(next)
    },
    [controlled, onChangeRef]
  )

  return [current, set, latest] as const
}

type TreeContextValue = {
  variant: TreeVariant
  size: TreeSize
  selectionMode: TreeSelectionMode
  checkboxes: boolean
  disabled: boolean
  hiddenUntilFound: boolean
  expanded: Set<string>
  selected: Set<string>
  checkStates: Map<string, TreeCheckState>
  activeValue: string | null
  registry: TreeRegistry
  setExpanded: (value: string, expanded: boolean) => void
  activate: (
    value: string,
    options: { range?: boolean; toggle?: boolean; expand?: boolean }
  ) => void
  toggleExpanded: (value: string) => void
  toggleCheck: (value: string) => void
  setActiveValue: (value: string) => void
}

const TreeContext = React.createContext<TreeContextValue | null>(null)

function useTreeContext(part: string) {
  const context = React.useContext(TreeContext)
  if (!context) {
    throw new Error(`${part} must be used within <Tree>.`)
  }
  return context
}

type TreeItemContextValue = {
  value: string
  depth: number
  disabled: boolean
  expanded: boolean
  hasGroup: boolean
  groupId: string
  registerGroup: () => () => void
}

const TreeItemContext = React.createContext<TreeItemContextValue | null>(null)

function useTreeItemContext(part: string) {
  const context = React.useContext(TreeItemContext)
  if (!context) {
    throw new Error(`${part} must be used within <TreeItem>.`)
  }
  return context
}

const rowSelector = '[data-slot="tree-item-label"]'

function ownsRow(root: HTMLElement, row: Element) {
  return row.closest('[data-slot="tree"]') === root
}

function isRowVisible(root: HTMLElement, row: HTMLElement) {
  let item = row.parentElement?.parentElement?.closest<HTMLElement>(
    '[data-slot="tree-item"]'
  )
  while (item && root.contains(item)) {
    if (!item.hasAttribute("data-open")) {
      return false
    }
    item = item.parentElement?.closest<HTMLElement>('[data-slot="tree-item"]')
  }
  return true
}

function getRows(root: HTMLElement) {
  return Array.from(root.querySelectorAll<HTMLElement>(rowSelector)).filter(
    (row) =>
      ownsRow(root, row) &&
      row.getAttribute("aria-disabled") !== "true" &&
      isRowVisible(root, row)
  )
}

function getRowValue(row: HTMLElement) {
  return row.dataset.value ?? ""
}

function getRowGroup(row: HTMLElement) {
  return row.parentElement?.querySelector<HTMLElement>(
    ':scope > [data-slot="tree-group"]'
  )
}

function getRowText(row: HTMLElement) {
  return (
    row.querySelector('[data-slot="tree-item-text"]')?.textContent ??
    row.textContent ??
    ""
  )
    .trim()
    .toLowerCase()
}

function isRtl(root: HTMLElement) {
  return (
    root.closest("[dir]")?.getAttribute("dir") === "rtl" ||
    getComputedStyle(root).direction === "rtl"
  )
}

function focusRow(row: HTMLElement | undefined) {
  if (!row) {
    return false
  }
  row.focus()
  row.scrollIntoView?.({ block: "nearest" })
  return true
}

type TreeProps = useRender.ComponentProps<"div"> & {
  variant?: TreeVariant
  size?: TreeSize
  selectionMode?: TreeSelectionMode
  selectedValues?: string[]
  defaultSelectedValues?: string[]
  onSelectedValuesChange?: (values: string[]) => void
  expandedValues?: string[]
  defaultExpandedValues?: string[]
  onExpandedValuesChange?: (values: string[]) => void
  checkboxes?: boolean
  checkedValues?: string[]
  defaultCheckedValues?: string[]
  onCheckedValuesChange?: (values: string[]) => void
  disabled?: boolean
  hiddenUntilFound?: boolean
}

function Tree({
  className,
  render,
  variant = "default",
  size = "default",
  selectionMode = "single",
  selectedValues,
  defaultSelectedValues,
  onSelectedValuesChange,
  expandedValues,
  defaultExpandedValues,
  onExpandedValuesChange,
  checkboxes = false,
  checkedValues,
  defaultCheckedValues,
  onCheckedValuesChange,
  disabled = false,
  hiddenUntilFound = true,
  onKeyDown,
  ref,
  ...props
}: TreeProps) {
  const resolvedVariant = variant ?? "default"
  const resolvedSize = size ?? "default"
  const [registry] = React.useState(createTreeRegistry)
  const version = React.useSyncExternalStore(
    registry.subscribe,
    registry.getVersion,
    registry.getVersion
  )
  const rootRef = React.useRef<HTMLElement | null>(null)
  const anchorRef = React.useRef<string | null>(null)
  const typeaheadRef = React.useRef({ buffer: "", timer: 0 })
  const [activeValue, setActiveValue] = React.useState<string | null>(null)

  const [expandedList, setExpandedList, latestExpanded] = useControllableValues(
    expandedValues,
    defaultExpandedValues,
    onExpandedValuesChange
  )
  const [selectedList, setSelectedList] = useControllableValues(
    selectedValues,
    defaultSelectedValues,
    onSelectedValuesChange
  )
  const [checkedList, setCheckedList] = useControllableValues(
    checkedValues,
    defaultCheckedValues,
    onCheckedValuesChange
  )

  const expanded = React.useMemo(() => new Set(expandedList), [expandedList])
  const selected = React.useMemo(() => new Set(selectedList), [selectedList])
  const children = React.useMemo(
    () => getChildren(registry.nodes),
    [registry, version]
  )
  const checkStates = React.useMemo(
    () => getCheckStates(children, new Set(checkedList)),
    [children, checkedList]
  )
  const latest = useLatest({ children, checkStates, selectionMode, disabled })

  const commitExpanded = React.useCallback(
    (update: (previous: string[]) => string[]) => {
      const before = new Set(latestExpanded.current)
      setExpandedList(update)
      const after = new Set(latestExpanded.current)
      for (const value of new Set([...before, ...after])) {
        if (before.has(value) !== after.has(value)) {
          registry.nodes.get(value)?.onExpandedChange(after.has(value))
        }
      }
    },
    [latestExpanded, registry, setExpandedList]
  )

  const setExpanded = React.useCallback(
    (value: string, open: boolean) => {
      commitExpanded((previous) =>
        open
          ? previous.includes(value)
            ? previous
            : [...previous, value]
          : previous.filter((item) => item !== value)
      )
    },
    [commitExpanded]
  )

  const toggleExpanded = React.useCallback(
    (value: string) => {
      setExpanded(value, !latestExpanded.current.includes(value))
    },
    [latestExpanded, setExpanded]
  )

  const selectRange = React.useCallback(
    (to: string) => {
      const root = rootRef.current
      const from = anchorRef.current ?? to
      if (!root) {
        return
      }
      const values = getRows(root).map(getRowValue)
      const start = values.indexOf(from)
      const end = values.indexOf(to)
      if (start === -1 || end === -1) {
        setSelectedList(() => [to])
        return
      }
      setSelectedList(() =>
        values.slice(Math.min(start, end), Math.max(start, end) + 1)
      )
    },
    [setSelectedList]
  )

  const activate = React.useCallback<TreeContextValue["activate"]>(
    (value, { range = false, toggle = false, expand = true }) => {
      const { selectionMode: mode } = latest.current
      if (mode === "single") {
        anchorRef.current = value
        setSelectedList(() => [value])
      } else if (mode === "multiple") {
        if (range) {
          selectRange(value)
        } else if (toggle) {
          anchorRef.current = value
          setSelectedList((previous) =>
            previous.includes(value)
              ? previous.filter((item) => item !== value)
              : [...previous, value]
          )
        } else {
          anchorRef.current = value
          setSelectedList(() => [value])
        }
      }
      if (expand && !range && !toggle) {
        const root = rootRef.current
        const row = root
          ? getRows(root).find((item) => getRowValue(item) === value)
          : undefined
        if (row && getRowGroup(row)) {
          toggleExpanded(value)
        }
      }
    },
    [latest, selectRange, setSelectedList, toggleExpanded]
  )

  const toggleCheck = React.useCallback(
    (value: string) => {
      const { children: kids, checkStates: states } = latest.current
      const next = toggleChecked(value, registry.nodes, kids, states)
      if (next) {
        setCheckedList(() => next)
      }
    },
    [latest, registry, setCheckedList]
  )

  React.useLayoutEffect(() => {
    const root = rootRef.current
    if (!root) {
      return
    }
    const rows = getRows(root)
    if (rows.length === 0) {
      return
    }
    if (rows.some((row) => getRowValue(row) === activeValue)) {
      return
    }
    let fallback: HTMLElement | undefined
    let parent =
      activeValue !== null
        ? (registry.nodes.get(activeValue)?.parent ?? null)
        : null
    while (parent !== null && !fallback) {
      const value = parent
      fallback = rows.find((row) => getRowValue(row) === value)
      parent = registry.nodes.get(value)?.parent ?? null
    }
    fallback ??=
      rows.find(
        (row) =>
          row.hasAttribute("data-selected") || row.hasAttribute("data-checked")
      ) ?? rows[0]
    const focused = root.ownerDocument.activeElement
    if (
      focused instanceof HTMLElement &&
      focused.matches(rowSelector) &&
      ownsRow(root, focused) &&
      !rows.includes(focused)
    ) {
      fallback.focus({ preventScroll: true })
    }
    setActiveValue(getRowValue(fallback))
  }, [activeValue, expanded, registry, version, disabled])

  React.useEffect(() => {
    const typeahead = typeaheadRef.current
    return () => window.clearTimeout(typeahead.timer)
  }, [])

  const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
    const root = event.currentTarget
    const row = event.target as HTMLElement
    if (
      !row.matches?.(rowSelector) ||
      !ownsRow(root, row) ||
      row.getAttribute("aria-disabled") === "true"
    ) {
      return
    }

    const rtl = isRtl(root)
    const key =
      event.key === "ArrowRight"
        ? rtl
          ? "ArrowLeft"
          : "ArrowRight"
        : event.key === "ArrowLeft"
          ? rtl
            ? "ArrowRight"
            : "ArrowLeft"
          : event.key
    const value = getRowValue(row)
    const rows = getRows(root)
    const index = rows.indexOf(row)
    const group = getRowGroup(row)
    const isExpanded = latestExpanded.current.includes(value)
    const modifier = event.altKey || event.ctrlKey || event.metaKey
    const { selectionMode: mode } = latest.current
    const multiple = mode === "multiple" && !checkboxes

    const move = (target: HTMLElement | undefined) => {
      event.preventDefault()
      if (!target) {
        return
      }
      focusRow(target)
      if (event.shiftKey && multiple) {
        anchorRef.current ??= value
        selectRange(getRowValue(target))
      }
    }

    switch (key) {
      case "ArrowDown":
        if (!modifier) {
          move(rows[index + 1])
        }
        return
      case "ArrowUp":
        if (!modifier) {
          move(rows[index - 1])
        }
        return
      case "Home":
        if (!event.altKey && !event.metaKey) {
          move(rows[0])
        }
        return
      case "End":
        if (!event.altKey && !event.metaKey) {
          move(rows[rows.length - 1])
        }
        return
      case "ArrowRight": {
        if (modifier || event.shiftKey || !group) {
          return
        }
        event.preventDefault()
        if (!isExpanded) {
          setExpanded(value, true)
          return
        }
        const next = rows[index + 1]
        if (next && group.contains(next)) {
          focusRow(next)
        }
        return
      }
      case "ArrowLeft": {
        if (modifier || event.shiftKey) {
          return
        }
        event.preventDefault()
        if (group && isExpanded) {
          setExpanded(value, false)
          return
        }
        const parentRow = row.parentElement?.parentElement
          ?.closest('[data-slot="tree-item"]')
          ?.querySelector<HTMLElement>(`:scope > ${rowSelector}`)
        if (parentRow && ownsRow(root, parentRow)) {
          focusRow(parentRow)
        }
        return
      }
      case "Enter":
        if (modifier || event.shiftKey || row.tagName === "A") {
          return
        }
        event.preventDefault()
        if (checkboxes) {
          toggleCheck(value)
        } else {
          activate(value, {})
        }
        return
      case " ":
        if (modifier) {
          return
        }
        event.preventDefault()
        if (checkboxes) {
          toggleCheck(value)
        } else if (mode === "multiple") {
          activate(value, {
            toggle: !event.shiftKey,
            range: event.shiftKey,
            expand: false,
          })
        } else if (mode === "single") {
          activate(value, { expand: false })
        } else if (group) {
          toggleExpanded(value)
        }
        return
      case "*": {
        event.preventDefault()
        const parentItem = row.parentElement?.parentElement
        const siblings = rows.filter(
          (item) => item.parentElement?.parentElement === parentItem
        )
        commitExpanded((previous) => {
          const next = new Set(previous)
          for (const sibling of siblings) {
            if (getRowGroup(sibling)) {
              next.add(getRowValue(sibling))
            }
          }
          return [...next]
        })
        return
      }
      default:
        break
    }

    if (
      multiple &&
      (event.ctrlKey || event.metaKey) &&
      !event.altKey &&
      event.key.toLowerCase() === "a"
    ) {
      event.preventDefault()
      setSelectedList(() => rows.map(getRowValue))
      return
    }

    if (event.key.length === 1 && !modifier && event.key !== " ") {
      const typeahead = typeaheadRef.current
      window.clearTimeout(typeahead.timer)
      typeahead.buffer += event.key.toLowerCase()
      typeahead.timer = window.setTimeout(() => {
        typeahead.buffer = ""
      }, 500)
      const buffer = typeahead.buffer
      const repeated = buffer.split("").every((char) => char === buffer[0])
      const search = repeated ? buffer[0] : buffer
      const ordered = [
        ...rows.slice(index + (repeated || buffer.length === 1 ? 1 : 0)),
        ...rows.slice(0, index + (repeated || buffer.length === 1 ? 1 : 0)),
      ]
      const match = ordered.find((item) => getRowText(item).startsWith(search))
      if (match) {
        event.preventDefault()
        focusRow(match)
      }
    }
  }

  const setRootRef = React.useCallback(
    (node: HTMLElement | null) => {
      rootRef.current = node
      if (typeof ref === "function") {
        ref(node as HTMLDivElement)
      } else if (ref) {
        ref.current = node as HTMLDivElement
      }
    },
    [ref]
  )

  const context = React.useMemo<TreeContextValue>(
    () => ({
      variant: resolvedVariant,
      size: resolvedSize,
      selectionMode,
      checkboxes,
      disabled,
      hiddenUntilFound,
      expanded,
      selected,
      checkStates,
      activeValue,
      registry,
      setExpanded,
      activate,
      toggleExpanded,
      toggleCheck,
      setActiveValue,
    }),
    [
      resolvedVariant,
      resolvedSize,
      selectionMode,
      checkboxes,
      disabled,
      hiddenUntilFound,
      expanded,
      selected,
      checkStates,
      activeValue,
      registry,
      setExpanded,
      activate,
      toggleExpanded,
      toggleCheck,
    ]
  )

  const element = useRender({
    defaultTagName: "div",
    render,
    ref: setRootRef,
    props: mergeProps<"div">(
      {
        role: "tree",
        "aria-multiselectable":
          selectionMode === "multiple" && !checkboxes ? true : undefined,
        "aria-disabled": disabled || undefined,
        className: cn(
          treeVariants({ variant: resolvedVariant, size: resolvedSize }),
          className
        ),
        onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => {
          onKeyDown?.(event)
          if (!event.defaultPrevented) {
            handleKeyDown(event)
          }
        },
      },
      props,
      {
        "data-slot": "tree",
        "data-variant": resolvedVariant,
        "data-size": resolvedSize,
      } as Record<string, string>
    ),
  })

  return <TreeContext.Provider value={context}>{element}</TreeContext.Provider>
}

type TreeItemProps = Omit<
  CollapsiblePrimitive.Root.Props,
  "open" | "defaultOpen" | "onOpenChange"
> & {
  value: string
  onExpandedChange?: (expanded: boolean) => void
}

function TreeItem({
  value,
  disabled: disabledProp = false,
  onExpandedChange,
  className,
  ...props
}: TreeItemProps) {
  const tree = useTreeContext("TreeItem")
  const parent = React.useContext(TreeItemContext)
  const disabled = tree.disabled || (parent?.disabled ?? false) || disabledProp
  const expanded = tree.expanded.has(value)
  const depth = parent ? parent.depth + 1 : 1
  const parentValue = parent?.value ?? null
  const groupId = React.useId()
  const [hasGroup, setHasGroup] = React.useState(false)
  const onExpandedChangeRef = useLatest(onExpandedChange)
  const { registry, setExpanded } = tree

  React.useLayoutEffect(
    () =>
      registry.register(value, {
        parent: parentValue,
        disabled,
        onExpandedChange: (open) => onExpandedChangeRef.current?.(open),
      }),
    [registry, value, parentValue, disabled, onExpandedChangeRef]
  )

  const registerGroup = React.useCallback(() => {
    setHasGroup(true)
    return () => setHasGroup(false)
  }, [])

  const context = React.useMemo<TreeItemContextValue>(
    () => ({
      value,
      depth,
      disabled,
      expanded,
      hasGroup,
      groupId,
      registerGroup,
    }),
    [value, depth, disabled, expanded, hasGroup, groupId, registerGroup]
  )

  return (
    <TreeItemContext.Provider value={context}>
      <CollapsiblePrimitive.Root
        data-slot="tree-item"
        role="none"
        open={expanded}
        onOpenChange={(open) => {
          if (!disabled) {
            setExpanded(value, open)
          }
        }}
        disabled={disabled}
        className={mergeClassName(
          treeItemVariants({ variant: tree.variant }),
          className
        )}
        {...props}
      />
    </TreeItemContext.Provider>
  )
}

type TreeItemLabelState = {
  expanded: boolean
  selected: boolean
  disabled: boolean
  checked: TreeCheckState
}

type TreeItemLabelProps = Omit<
  useRender.ComponentProps<"div", TreeItemLabelState>,
  "className"
> & {
  className?: string
  icon?: React.ReactNode
  expandedIcon?: React.ReactNode
  meta?: React.ReactNode
}

function TreeItemLabel({
  className,
  children,
  icon,
  expandedIcon,
  meta,
  render,
  onClick,
  onFocus,
  ...props
}: TreeItemLabelProps) {
  const tree = useTreeContext("TreeItemLabel")
  const item = useTreeItemContext("TreeItemLabel")
  const { value, depth, disabled, expanded, hasGroup, groupId } = item
  const selectable = tree.selectionMode !== "none" && !tree.checkboxes
  const selected = selectable && tree.selected.has(value)
  const checked = tree.checkStates.get(value) ?? "unchecked"
  const active = tree.activeValue === value && !disabled ? 0 : (-1 as const)

  const state: TreeItemLabelState = {
    expanded: hasGroup && expanded,
    selected,
    disabled,
    checked,
  }

  return useRender({
    defaultTagName: "div",
    render,
    state,
    props: mergeProps<"div">(
      {
        role: "treeitem",
        tabIndex: active,
        "aria-level": depth,
        "aria-expanded": hasGroup ? expanded : undefined,
        "aria-owns": hasGroup ? groupId : undefined,
        "aria-selected": selectable ? selected : undefined,
        "aria-checked": tree.checkboxes
          ? checked === "mixed"
            ? "mixed"
            : checked === "checked"
          : undefined,
        "aria-disabled": disabled || undefined,
        className: cn(
          "group/tree-item-label relative flex h-(--tree-row-height) min-w-0 shrink-0 cursor-default items-center gap-(--tree-gap) rounded-md px-(--tree-row-px) text-start text-foreground no-underline transition-[background-color,color,box-shadow] duration-150 outline-none select-none [-webkit-tap-highlight-color:transparent] hover:bg-muted/60 focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-ring focus-visible:outline-solid focus-visible:ring-inset data-[selected]:bg-muted data-[selected]:hover:bg-muted forced-colors:data-[selected]:outline-2 forced-colors:data-[selected]:-outline-offset-2 forced-colors:data-[selected]:outline-solid data-disabled:pointer-events-none data-disabled:opacity-50",
          className
        ),
        onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
          onFocus?.(event)
          if (event.target === event.currentTarget) {
            tree.setActiveValue(value)
          }
        },
        onClick: (event: React.MouseEvent<HTMLDivElement>) => {
          onClick?.(event)
          if (event.defaultPrevented || disabled) {
            return
          }
          const indicator = (event.target as Element).closest?.(
            '[data-slot="tree-item-indicator"]'
          )
          if (indicator && event.currentTarget.contains(indicator)) {
            if (hasGroup) {
              tree.toggleExpanded(value)
            }
            return
          }
          if (tree.checkboxes) {
            tree.toggleCheck(value)
            return
          }
          const pointerType = (event.nativeEvent as PointerEvent).pointerType
          tree.activate(value, {
            range: event.shiftKey,
            toggle:
              event.metaKey ||
              event.ctrlKey ||
              (pointerType === "touch" && tree.selectionMode === "multiple"),
          })
        },
        children: (
          <>
            <span
              data-slot="tree-item-indicator"
              aria-hidden="true"
              className="relative grid size-(--tree-chevron) shrink-0 place-items-center text-muted-foreground transition-colors duration-150 group-hover/tree-item-label:text-foreground after:absolute after:-inset-x-1 after:-inset-y-2 rtl:-scale-x-100 [&>svg]:invisible [&>svg]:size-4 [&>svg]:transition-[rotate] [&>svg]:duration-200 [&>svg]:ease-out-quint group-data-expanded/tree-item-label:[&>svg]:rotate-90 motion-reduce:[&>svg]:transition-none"
            >
              <IconChevronRight />
            </span>
            {tree.checkboxes ? (
              <Checkbox
                checked={checked === "checked"}
                indeterminate={checked === "mixed"}
                disabled={disabled}
                tabIndex={-1}
                aria-hidden
                className="pointer-events-none group-hover/tree-item-label:inset-ring-foreground/70"
              />
            ) : null}
            {icon ? (
              <span
                data-slot="tree-item-icon"
                aria-hidden="true"
                className="grid size-4 shrink-0 place-items-center text-muted-foreground *:col-start-1 *:row-start-1 *:transition-opacity *:duration-150 motion-reduce:*:transition-none [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
              >
                {expandedIcon ? (
                  <>
                    <span className="grid place-items-center group-data-expanded/tree-item-label:opacity-0">
                      {icon}
                    </span>
                    <span className="grid place-items-center opacity-0 group-data-expanded/tree-item-label:opacity-100">
                      {expandedIcon}
                    </span>
                  </>
                ) : (
                  icon
                )}
              </span>
            ) : null}
            <span
              data-slot="tree-item-text"
              className="min-w-0 flex-1 truncate"
            >
              {children}
            </span>
            {meta != null && meta !== false ? (
              <span
                data-slot="tree-item-meta"
                className="ms-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground tabular-nums"
              >
                {meta}
              </span>
            ) : null}
          </>
        ),
      },
      props,
      {
        "data-slot": "tree-item-label",
        "data-value": value,
        "data-expanded": state.expanded ? "" : undefined,
        "data-selected": selected ? "" : undefined,
        "data-checked": checked === "checked" ? "" : undefined,
        "data-indeterminate": checked === "mixed" ? "" : undefined,
        "data-disabled": disabled ? "" : undefined,
      } as Record<string, string>
    ),
  })
}

function TreeGroup({
  className,
  children,
  ...props
}: Omit<
  CollapsiblePrimitive.Panel.Props,
  "className" | "hiddenUntilFound" | "keepMounted"
> & {
  className?: string
}) {
  const tree = useTreeContext("TreeGroup")
  const { registerGroup, groupId } = useTreeItemContext("TreeGroup")

  React.useLayoutEffect(() => registerGroup(), [registerGroup])

  return (
    <CollapsiblePrimitive.Panel
      id={groupId}
      role="group"
      data-slot="tree-group"
      hiddenUntilFound={tree.hiddenUntilFound}
      keepMounted
      className="group/tree-group h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 ease-out-quint data-ending-style:h-0 data-ending-style:duration-150 data-starting-style:h-0 motion-reduce:transition-none"
      {...props}
    >
      <div
        data-slot="tree-group-inner"
        className={cn(
          treeGroupVariants({ variant: tree.variant }),
          "transition-[opacity,translate] duration-200 ease-out-quint group-data-ending-style/tree-group:-translate-y-1 group-data-ending-style/tree-group:opacity-0 group-data-ending-style/tree-group:duration-150 group-data-starting-style/tree-group:-translate-y-1 group-data-starting-style/tree-group:opacity-0 motion-reduce:transition-none",
          className
        )}
      >
        {children}
      </div>
    </CollapsiblePrimitive.Panel>
  )
}

export { Tree, TreeItem, TreeItemLabel, TreeGroup, treeVariants }
export type {
  TreeProps,
  TreeItemProps,
  TreeItemLabelProps,
  TreeCheckState,
  TreeSelectionMode,
}
```

```tsx title="components/ui/checkbox.tsx"
"use client"

import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { CheckboxGroup as CheckboxGroupPrimitive } from "@base-ui/react/checkbox-group"
import { IconCheck, IconMinus } from "@tabler/icons-react"
import { cn } from "cn"

function mergeClassName<State>(
  base: string,
  className: string | ((state: State) => string | undefined) | undefined
) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

const checkboxClassName =
  "peer relative inline-flex size-4 shrink-0 items-center justify-center rounded-[calc(var(--radius-sm)*0.5)] inset-ring-(length:--hairline) forced-colors:border inset-ring-muted-foreground/80 bg-background text-primary-foreground transition-[background-color,border-color,box-shadow,scale] duration-150 ease-out-quint outline-none focus-visible:outline-hidden select-none after:absolute after:-inset-2.5 pointer-coarse:after:-inset-3.5 hover:inset-ring-foreground/70 [@media(hover:hover)]:in-[label:hover]:inset-ring-foreground/70 focus-visible:inset-ring-ring focus-visible:ring-3 focus-visible:ring-focus-ring motion-safe:active:scale-95 motion-safe:in-[label:active]:scale-95 data-checked:inset-ring-primary data-checked:bg-primary data-indeterminate:inset-ring-primary data-indeterminate:bg-primary data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:inset-ring-muted-foreground/80 data-disabled:[@media(hover:hover)]:in-[label:hover]:inset-ring-muted-foreground/80 motion-safe:data-disabled:active:scale-100 motion-safe:data-disabled:in-[label:active]:scale-100 data-readonly:cursor-default motion-safe:data-readonly:active:scale-100 motion-safe:data-readonly:in-[label:active]:scale-100 aria-invalid:inset-ring-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-invalid:inset-ring-destructive data-invalid:ring-3 data-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:inset-ring-destructive/50 dark:aria-invalid:ring-destructive/40 dark:data-invalid:inset-ring-destructive/50 dark:data-invalid:ring-destructive/40 data-checked:data-invalid:inset-ring-primary data-checked:aria-invalid:inset-ring-primary dark:data-checked:bg-primary dark:data-indeterminate:bg-primary"

function CheckboxMark({ indeterminate }: { indeterminate: boolean }) {
  const markRef = React.useRef<SVGSVGElement>(null)

  React.useLayoutEffect(() => {
    const mark = markRef.current
    const root = mark?.closest("[data-slot=checkbox]")

    if (mark && root?.hasAttribute("data-mounted")) {
      mark.setAttribute("data-draw", "")
    }
  }, [])

  const Icon = indeterminate ? IconMinus : IconCheck

  return (
    <Icon
      ref={markRef}
      aria-hidden
      stroke={2.75}
      className={cn(
        "pointer-events-none size-3.5 motion-safe:data-draw:animate-checkbox-draw",
        indeterminate
          ? "motion-safe:data-draw:[stroke-dasharray:15] motion-safe:data-draw:[stroke-dashoffset:15]"
          : "motion-safe:data-draw:[stroke-dasharray:22] motion-safe:data-draw:[stroke-dashoffset:22]"
      )}
    />
  )
}

function Checkbox({
  className,
  children,
  ref,
  ...props
}: CheckboxPrimitive.Root.Props) {
  const rootRef = React.useRef<HTMLElement | null>(null)

  const setRoot = React.useCallback(
    (node: HTMLElement | null) => {
      rootRef.current = node
      if (typeof ref === "function") {
        ref(node)
      } else if (ref) {
        ref.current = node
      }
    },
    [ref]
  )

  React.useEffect(() => {
    const frame = requestAnimationFrame(() => {
      rootRef.current?.setAttribute("data-mounted", "")
    })
    return () => cancelAnimationFrame(frame)
  }, [])

  return (
    <CheckboxPrimitive.Root
      ref={setRoot}
      className={mergeClassName(checkboxClassName, className)}
      {...props}
      data-slot="checkbox"
    >
      <CheckboxPrimitive.Indicator
        data-slot="checkbox-indicator"
        className="grid place-content-center text-current transition-[opacity,scale] duration-100 ease-out-quint data-ending-style:opacity-0 motion-safe:data-ending-style:scale-75"
        render={(indicatorProps, state) => (
          <span {...indicatorProps}>
            <CheckboxMark
              key={state.indeterminate ? "indeterminate" : "checked"}
              indeterminate={state.indeterminate}
            />
          </span>
        )}
      />
      {children}
    </CheckboxPrimitive.Root>
  )
}

function CheckboxGroup({ className, ...props }: CheckboxGroupPrimitive.Props) {
  return (
    <CheckboxGroupPrimitive
      className={mergeClassName("flex flex-col gap-3", className)}
      {...props}
      data-slot="checkbox-group"
    />
  )
}

export { Checkbox, CheckboxGroup }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemLabel,
} from "@/components/ui/tree"
```

```tsx
<Tree aria-label="Files" defaultExpandedValues={["src"]}>
  <TreeItem value="src">
    <TreeItemLabel icon={<IconFolder />}>src</TreeItemLabel>
    <TreeGroup>
      <TreeItem value="index">
        <TreeItemLabel icon={<IconFile />}>index.ts</TreeItemLabel>
      </TreeItem>
    </TreeGroup>
  </TreeItem>
</Tree>
```

An item with a `<TreeGroup />` is a branch: it gets a chevron, opens and closes, and answers to the arrow keys. Every item needs a `value` that is unique within the tree.

## Composition

```text
Tree
└── TreeItem
    ├── TreeItemLabel
    └── TreeGroup
        └── TreeItem
            └── TreeItemLabel
```

## Examples

### Indent guides

`variant="lines"` draws a guide for each level. The guide of the branch that holds the selected or focused row gets darker, so you can see where you are in deep trees.

```tsx title="components/examples/tree/lines.tsx"
import { IconFile, IconFolder, IconFolderOpen } from "@tabler/icons-react"

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

type Node = { name: string; children?: Node[] }

const files: Node[] = [
  {
    name: "packages",
    children: [
      {
        name: "core",
        children: [
          {
            name: "src",
            children: [
              { name: "index.ts" },
              { name: "store.ts" },
              {
                name: "utils",
                children: [{ name: "clamp.ts" }, { name: "debounce.ts" }],
              },
            ],
          },
          { name: "package.json" },
        ],
      },
      {
        name: "react",
        children: [{ name: "index.tsx" }, { name: "package.json" }],
      },
    ],
  },
  { name: "turbo.json" },
]

function renderNodes(nodes: Node[], parent = ""): React.ReactNode {
  return nodes.map((node) => {
    const value = `${parent}/${node.name}`
    return node.children ? (
      <TreeItem key={value} value={value}>
        <TreeItemLabel icon={<IconFolder />} expandedIcon={<IconFolderOpen />}>
          {node.name}
        </TreeItemLabel>
        <TreeGroup>{renderNodes(node.children, value)}</TreeGroup>
      </TreeItem>
    ) : (
      <TreeItem key={value} value={value}>
        <TreeItemLabel icon={<IconFile />}>{node.name}</TreeItemLabel>
      </TreeItem>
    )
  })
}

export function TreeLines() {
  return (
    <Tree
      aria-label="Monorepo"
      variant="lines"
      defaultExpandedValues={[
        "/packages",
        "/packages/core",
        "/packages/core/src",
        "/packages/core/src/utils",
      ]}
      defaultSelectedValues={["/packages/core/src/utils/clamp.ts"]}
      className="w-full max-w-xs"
    >
      {renderNodes(files)}
    </Tree>
  )
}
```

### Connectors

`variant="connectors"` joins each item to its parent with an elbow, like a terminal tree listing. Use `meta` for versions, sizes or counts.

```tsx title="components/examples/tree/connectors.tsx"
import { IconPackage } from "@tabler/icons-react"

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

type Dependency = { name: string; version: string; dependencies?: Dependency[] }

const dependencies: Dependency[] = [
  {
    name: "next",
    version: "16.3.8",
    dependencies: [
      { name: "@next/env", version: "16.3.8" },
      {
        name: "postcss",
        version: "8.5.6",
        dependencies: [
          { name: "nanoid", version: "3.3.11" },
          { name: "picocolors", version: "1.1.1" },
          { name: "source-map-js", version: "1.2.1" },
        ],
      },
      { name: "styled-jsx", version: "5.1.7" },
    ],
  },
  {
    name: "react-dom",
    version: "19.3.0",
    dependencies: [{ name: "scheduler", version: "0.27.0" }],
  },
]

function renderDependencies(items: Dependency[], parent = ""): React.ReactNode {
  return items.map((item) => {
    const value = `${parent}/${item.name}`
    return (
      <TreeItem key={value} value={value}>
        <TreeItemLabel icon={<IconPackage />} meta={item.version}>
          {item.name}
        </TreeItemLabel>
        {item.dependencies ? (
          <TreeGroup>{renderDependencies(item.dependencies, value)}</TreeGroup>
        ) : null}
      </TreeItem>
    )
  })
}

export function TreeConnectors() {
  return (
    <Tree
      aria-label="Dependencies"
      variant="connectors"
      selectionMode="none"
      defaultExpandedValues={["/next", "/next/postcss", "/react-dom"]}
      className="w-full max-w-xs"
    >
      {renderDependencies(dependencies)}
    </Tree>
  )
}
```

### Checkboxes

With `checkboxes`, checking a branch checks every enabled item inside it, and a partly checked branch shows a dash. Checking a branch also counts as checking children you load later. Clicking a row checks it, and the chevron opens it.

```tsx title="components/examples/tree/checkboxes.tsx"
"use client"

import * as React from "react"

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

const permissions = [
  {
    value: "projects",
    label: "Projects",
    children: [
      { value: "projects.view", label: "View projects" },
      { value: "projects.edit", label: "Edit projects" },
      { value: "projects.delete", label: "Delete projects" },
    ],
  },
  {
    value: "members",
    label: "Members",
    children: [
      { value: "members.invite", label: "Invite members" },
      { value: "members.remove", label: "Remove members" },
    ],
  },
  {
    value: "billing",
    label: "Billing",
    children: [
      { value: "billing.invoices", label: "View invoices" },
      { value: "billing.plan", label: "Change plan", disabled: true },
    ],
  },
]

const leaves = permissions.flatMap((group) => group.children)

export function TreeCheckboxes() {
  const [checked, setChecked] = React.useState<string[]>([
    "projects.view",
    "members.invite",
    "members.remove",
  ])
  const count = leaves.filter((leaf) => checked.includes(leaf.value)).length

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Tree
        aria-label="Permissions"
        checkboxes
        checkedValues={checked}
        onCheckedValuesChange={setChecked}
        defaultExpandedValues={["projects", "billing"]}
      >
        {permissions.map((group) => (
          <TreeItem key={group.value} value={group.value}>
            <TreeItemLabel>{group.label}</TreeItemLabel>
            <TreeGroup>
              {group.children.map((permission) => (
                <TreeItem
                  key={permission.value}
                  value={permission.value}
                  disabled={"disabled" in permission}
                >
                  <TreeItemLabel
                    meta={"disabled" in permission ? "Owner only" : null}
                  >
                    {permission.label}
                  </TreeItemLabel>
                </TreeItem>
              ))}
            </TreeGroup>
          </TreeItem>
        ))}
      </Tree>
      <p className="text-sm text-muted-foreground" aria-live="polite">
        {count} of {leaves.length} permissions granted
      </p>
    </div>
  )
}
```

### Navigation

Render rows as links with `render`. Pass the current page to `selectedValues`, open its section, and set `aria-current` on its row.

```tsx title="components/examples/tree/navigation.tsx"
import Link from "next/link"

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

const sections = [
  {
    value: "getting-started",
    title: "Getting started",
    pages: [{ value: "installation", title: "Installation" }],
  },
  {
    value: "components",
    title: "Components",
    pages: [
      { value: "accordion", title: "Accordion" },
      { value: "collapsible", title: "Collapsible" },
      { value: "tabs", title: "Tabs" },
      { value: "tree", title: "Tree" },
    ],
  },
]

const current = "tree"

export function TreeNavigation() {
  return (
    <nav aria-label="Docs" className="w-full max-w-60">
      <Tree
        aria-label="Docs pages"
        size="sm"
        selectedValues={[current]}
        defaultExpandedValues={["components"]}
      >
        {sections.map((section) => (
          <TreeItem key={section.value} value={section.value}>
            <TreeItemLabel>{section.title}</TreeItemLabel>
            <TreeGroup>
              {section.pages.map((page) => (
                <TreeItem key={page.value} value={page.value}>
                  <TreeItemLabel
                    render={<Link href={`/docs/${page.value}`} />}
                    aria-current={page.value === current ? "page" : undefined}
                  >
                    {page.title}
                  </TreeItemLabel>
                </TreeItem>
              ))}
            </TreeGroup>
          </TreeItem>
        ))}
      </Tree>
    </nav>
  )
}
```

### Multiple selection

`selectionMode="multiple"` works like a file manager: click to select one row, Cmd/Ctrl+click to add or remove one, Shift+click to select a range. On touch screens a tap adds or removes the row.

```tsx title="components/examples/tree/multiple.tsx"
"use client"

import * as React from "react"
import { IconFile, IconFolder, IconFolderOpen } from "@tabler/icons-react"

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

const photos = ["beach.jpg", "city.jpg", "forest.jpg", "mountains.jpg"]
const documents = ["invoice.pdf", "notes.md", "resume.pdf"]

export function TreeMultiple() {
  const [selected, setSelected] = React.useState<string[]>(["city.jpg"])

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Tree
        aria-label="Library"
        selectionMode="multiple"
        selectedValues={selected}
        onSelectedValuesChange={setSelected}
        defaultExpandedValues={["photos", "documents"]}
      >
        <TreeItem value="photos">
          <TreeItemLabel
            icon={<IconFolder />}
            expandedIcon={<IconFolderOpen />}
          >
            Photos
          </TreeItemLabel>
          <TreeGroup>
            {photos.map((name) => (
              <TreeItem key={name} value={name}>
                <TreeItemLabel icon={<IconFile />}>{name}</TreeItemLabel>
              </TreeItem>
            ))}
          </TreeGroup>
        </TreeItem>
        <TreeItem value="documents">
          <TreeItemLabel
            icon={<IconFolder />}
            expandedIcon={<IconFolderOpen />}
          >
            Documents
          </TreeItemLabel>
          <TreeGroup>
            {documents.map((name) => (
              <TreeItem key={name} value={name}>
                <TreeItemLabel icon={<IconFile />}>{name}</TreeItemLabel>
              </TreeItem>
            ))}
          </TreeGroup>
        </TreeItem>
      </Tree>
      <p className="text-sm text-muted-foreground" aria-live="polite">
        {selected.length === 0
          ? "Nothing selected"
          : `${selected.length} selected`}
      </p>
    </div>
  )
}
```

### Controlled

Keep open branches in your own state with `expandedValues` and `onExpandedValuesChange`. This example also builds the tree from data with a recursive function.

```tsx title="components/examples/tree/controlled.tsx"
"use client"

import * as React from "react"
import { IconFile, IconFolder, IconFolderOpen } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import { Tree, TreeGroup, TreeItem, TreeItemLabel } from "@/components/ui/tree"

type Node = { id: string; name: string; children?: Node[] }

const nodes: Node[] = [
  {
    id: "src",
    name: "src",
    children: [
      {
        id: "hooks",
        name: "hooks",
        children: [
          { id: "use-media", name: "use-media.ts" },
          { id: "use-copy", name: "use-copy.ts" },
        ],
      },
      { id: "main", name: "main.tsx" },
    ],
  },
  {
    id: "tests",
    name: "tests",
    children: [{ id: "smoke", name: "smoke.test.ts" }],
  },
]

function branchIds(items: Node[]): string[] {
  return items.flatMap((item) =>
    item.children ? [item.id, ...branchIds(item.children)] : []
  )
}

function renderNodes(items: Node[]): React.ReactNode {
  return items.map((item) =>
    item.children ? (
      <TreeItem key={item.id} value={item.id}>
        <TreeItemLabel icon={<IconFolder />} expandedIcon={<IconFolderOpen />}>
          {item.name}
        </TreeItemLabel>
        <TreeGroup>{renderNodes(item.children)}</TreeGroup>
      </TreeItem>
    ) : (
      <TreeItem key={item.id} value={item.id}>
        <TreeItemLabel icon={<IconFile />}>{item.name}</TreeItemLabel>
      </TreeItem>
    )
  )
}

export function TreeControlled() {
  const [expanded, setExpanded] = React.useState<string[]>(["src"])
  const [selected, setSelected] = React.useState<string[]>([])

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <div className="flex gap-2">
        <Button
          variant="outline"
          size="sm"
          onClick={() => setExpanded(branchIds(nodes))}
        >
          Expand all
        </Button>
        <Button variant="outline" size="sm" onClick={() => setExpanded([])}>
          Collapse all
        </Button>
      </div>
      <Tree
        aria-label="Source"
        expandedValues={expanded}
        onExpandedValuesChange={setExpanded}
        selectedValues={selected}
        onSelectedValuesChange={setSelected}
      >
        {renderNodes(nodes)}
      </Tree>
      <p className="text-sm text-muted-foreground">
        Selected: {selected[0] ?? "none"}
      </p>
    </div>
  )
}
```

### Lazy loading

Fetch children in `onExpandedChange` on `<TreeItem />`. Render a disabled loading row until they arrive, so the branch keeps its chevron.

```tsx title="components/examples/tree/lazy.tsx"
"use client"

import * as React from "react"
import { IconFile, IconFolder, IconFolderOpen } from "@tabler/icons-react"

import { Spinner } from "@/components/ui/spinner"
import { Tree, TreeGroup, TreeItem, TreeItemLabel } from "@/components/ui/tree"

function listFiles(folder: string) {
  return new Promise<string[]>((resolve) =>
    setTimeout(
      () => resolve([`${folder}-1.log`, `${folder}-2.log`, `${folder}-3.log`]),
      900
    )
  )
}

function RemoteFolder({ name }: { name: string }) {
  const [files, setFiles] = React.useState<string[] | null>(null)
  const loading = React.useRef(false)

  return (
    <TreeItem
      value={name}
      onExpandedChange={(expanded) => {
        if (expanded && files === null && !loading.current) {
          loading.current = true
          listFiles(name).then(setFiles)
        }
      }}
    >
      <TreeItemLabel icon={<IconFolder />} expandedIcon={<IconFolderOpen />}>
        {name}
      </TreeItemLabel>
      <TreeGroup aria-busy={files === null}>
        {files === null ? (
          <TreeItem value={`${name}/loading`} disabled>
            <TreeItemLabel icon={<Spinner size="sm" label="Loading files" />}>
              Loading…
            </TreeItemLabel>
          </TreeItem>
        ) : (
          files.map((file) => (
            <TreeItem key={file} value={`${name}/${file}`}>
              <TreeItemLabel icon={<IconFile />}>{file}</TreeItemLabel>
            </TreeItem>
          ))
        )}
      </TreeGroup>
    </TreeItem>
  )
}

export function TreeLazy() {
  return (
    <Tree aria-label="Server logs" className="w-full max-w-xs">
      <RemoteFolder name="api" />
      <RemoteFolder name="worker" />
      <RemoteFolder name="cron" />
    </Tree>
  )
}
```

### Small

`size="sm"` uses shorter rows and tighter indents, for sidebars. Rows grow on touch screens in both sizes.

```tsx title="components/examples/tree/small.tsx"
import { IconHash, IconVolume } from "@tabler/icons-react"

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

export function TreeSmall() {
  return (
    <Tree
      aria-label="Channels"
      size="sm"
      defaultExpandedValues={["text", "voice"]}
      defaultSelectedValues={["general"]}
      className="w-full max-w-56"
    >
      <TreeItem value="text">
        <TreeItemLabel>Text channels</TreeItemLabel>
        <TreeGroup>
          <TreeItem value="general">
            <TreeItemLabel icon={<IconHash />} meta="12">
              general
            </TreeItemLabel>
          </TreeItem>
          <TreeItem value="design">
            <TreeItemLabel icon={<IconHash />}>design</TreeItemLabel>
          </TreeItem>
          <TreeItem value="releases">
            <TreeItemLabel icon={<IconHash />} meta="3">
              releases
            </TreeItemLabel>
          </TreeItem>
        </TreeGroup>
      </TreeItem>
      <TreeItem value="voice">
        <TreeItemLabel>Voice channels</TreeItemLabel>
        <TreeGroup>
          <TreeItem value="lounge">
            <TreeItemLabel icon={<IconVolume />}>Lounge</TreeItemLabel>
          </TreeItem>
        </TreeGroup>
      </TreeItem>
    </Tree>
  )
}
```

### Disabled

A disabled item can’t be selected, checked or opened, and the arrow keys skip it. Items inside a disabled branch are disabled too.

```tsx title="components/examples/tree/disabled.tsx"
import { IconFile, IconFolder, IconLock } from "@tabler/icons-react"

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

export function TreeDisabled() {
  return (
    <Tree
      aria-label="Shared drive"
      defaultExpandedValues={["team"]}
      className="w-full max-w-xs"
    >
      <TreeItem value="team">
        <TreeItemLabel icon={<IconFolder />}>Team</TreeItemLabel>
        <TreeGroup>
          <TreeItem value="roadmap">
            <TreeItemLabel icon={<IconFile />}>roadmap.md</TreeItemLabel>
          </TreeItem>
          <TreeItem value="salaries" disabled>
            <TreeItemLabel icon={<IconLock />}>salaries.xlsx</TreeItemLabel>
          </TreeItem>
          <TreeItem value="notes">
            <TreeItemLabel icon={<IconFile />}>notes.md</TreeItemLabel>
          </TreeItem>
        </TreeGroup>
      </TreeItem>
      <TreeItem value="archive" disabled>
        <TreeItemLabel icon={<IconLock />}>Archive</TreeItemLabel>
        <TreeGroup>
          <TreeItem value="2024">
            <TreeItemLabel icon={<IconFile />}>2024.zip</TreeItemLabel>
          </TreeItem>
        </TreeGroup>
      </TreeItem>
    </Tree>
  )
}
```

### Long names

Names cut off with an ellipsis and `meta` stays visible. Pass `title` to show the full name on hover.

```tsx title="components/examples/tree/long-names.tsx"
import { IconFile, IconFolder, IconFolderOpen } from "@tabler/icons-react"

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

const longFolder = "quarterly-financial-reports-and-board-presentations"
const longFile = "2026-q3-consolidated-revenue-forecast-final-v12-approved.xlsx"
const unbroken = "averyveryverylongfilenamewithoutanyspacesoranybreaks.txt"

export function TreeLongNames() {
  return (
    <Tree
      aria-label="Reports"
      variant="lines"
      defaultExpandedValues={["reports"]}
      className="w-full max-w-56"
    >
      <TreeItem value="reports">
        <TreeItemLabel
          icon={<IconFolder />}
          expandedIcon={<IconFolderOpen />}
          title={longFolder}
        >
          {longFolder}
        </TreeItemLabel>
        <TreeGroup>
          <TreeItem value="forecast">
            <TreeItemLabel icon={<IconFile />} meta="2.4 MB" title={longFile}>
              {longFile}
            </TreeItemLabel>
          </TreeItem>
          <TreeItem value="unbroken">
            <TreeItemLabel icon={<IconFile />} title={unbroken}>
              {unbroken}
            </TreeItemLabel>
          </TreeItem>
        </TreeGroup>
      </TreeItem>
    </Tree>
  )
}
```

### Right to left

Indents, connectors and chevrons mirror, and the left and right arrow keys swap.

```tsx title="components/examples/tree/rtl.tsx"
import { IconFile, IconFolder, IconFolderOpen } from "@tabler/icons-react"

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

export function TreeRtl() {
  return (
    <div dir="rtl" className="w-full max-w-xs">
      <Tree
        aria-label="المستندات"
        variant="connectors"
        defaultExpandedValues={["docs", "invoices"]}
        defaultSelectedValues={["march"]}
      >
        <TreeItem value="docs">
          <TreeItemLabel
            icon={<IconFolder />}
            expandedIcon={<IconFolderOpen />}
          >
            المستندات
          </TreeItemLabel>
          <TreeGroup>
            <TreeItem value="invoices">
              <TreeItemLabel
                icon={<IconFolder />}
                expandedIcon={<IconFolderOpen />}
              >
                الفواتير
              </TreeItemLabel>
              <TreeGroup>
                <TreeItem value="march">
                  <TreeItemLabel icon={<IconFile />} meta="١٢٠ ك.ب">
                    مارس.pdf
                  </TreeItemLabel>
                </TreeItem>
                <TreeItem value="april">
                  <TreeItemLabel icon={<IconFile />}>أبريل.pdf</TreeItemLabel>
                </TreeItem>
              </TreeGroup>
            </TreeItem>
            <TreeItem value="contract">
              <TreeItemLabel icon={<IconFile />}>العقد.docx</TreeItemLabel>
            </TreeItem>
          </TreeGroup>
        </TreeItem>
        <TreeItem value="notes">
          <TreeItemLabel icon={<IconFile />}>ملاحظات.txt</TreeItemLabel>
        </TreeItem>
      </Tree>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `↓` | Moves focus to the next visible row. |
| `↑` | Moves focus to the previous visible row. |
| `→` | Opens a closed branch. On an open branch, moves focus to its first child. |
| `←` | Closes an open branch. Otherwise moves focus to the parent. |
| `Home` | Moves focus to the first row. |
| `End` | Moves focus to the last visible row. |
| `Enter` | Selects the row and opens or closes it if it is a branch. Follows the link on link rows. |
| `Space` | Selects the row, adds or removes it in multiple mode, or checks it with checkboxes. |
| `Shift + ↑` `Shift + ↓` | Extends the selection in multiple mode. |
| `Cmd/Ctrl + A` | Selects every visible row in multiple mode. |
| `*` | Opens every branch at the focused row’s level. |
| `a–z` | Moves focus to the next row whose name starts with the typed letters. |

## Accessibility

The tree follows the WAI-ARIA tree view pattern. Each row is the `treeitem` and owns its group through `aria-owns`, so a row’s name is only its own label and link rows work as real links. Only one row is in the tab order at a time. When a branch closes around the focused row, focus moves to that branch instead of getting lost.

Closed groups stay in the page, so the browser’s find-in-page opens the folders around a match, and checking a branch sees every item inside it. Give the tree an `aria-label`.

## API reference

Branches open and close with the Base UI collapsible. `<TreeItem />` and `<TreeGroup />` accept the props of the parts they wrap.

### Tree

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "lines" \| "connectors"` | `"default"` |  |
| `size` | `"default" \| "sm"` | `"default"` |  |
| `selectionMode` | `"none" \| "single" \| "multiple"` | `"single"` |  |
| `selectedValues` | `string[]` | – |  |
| `defaultSelectedValues` | `string[]` | – |  |
| `onSelectedValuesChange` | `(values: string[]) => void` | – |  |
| `expandedValues` | `string[]` | – |  |
| `defaultExpandedValues` | `string[]` | – |  |
| `onExpandedValuesChange` | `(values: string[]) => void` | – |  |
| `checkboxes` | `boolean` | `false` | Show cascading checkboxes. Rows are checked instead of selected. |
| `checkedValues` | `string[]` | – | Checked items. A branch in the list counts as checking its children. |
| `defaultCheckedValues` | `string[]` | – |  |
| `onCheckedValuesChange` | `(values: string[]) => void` | – | Called with every checked leaf and every fully checked branch. |
| `disabled` | `boolean` | `false` |  |
| `hiddenUntilFound` | `boolean` | `true` | Let find-in-page open closed branches around a match. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="tree"` | Target the root in CSS. |
| `data-variant` | The current variant. |
| `data-size` | The current size. |
| `--tree-row-height` | Row height. Larger on touch screens. |
| `--tree-indent` | How far each level is indented. |

### TreeItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | – | Required. Unique within the tree. |
| `disabled` | `boolean` | `false` |  |
| `onExpandedChange` | `(expanded: boolean) => void` | – | Called when this item opens or closes. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="tree-item"` | Target items in CSS. |
| `data-open` | Present when the item is open. |
| `data-disabled` | Present when the item is disabled. |

### TreeItemLabel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `icon` | `ReactNode` | – | Shown before the label. |
| `expandedIcon` | `ReactNode` | – | Crossfades in for icon while the branch is open. |
| `meta` | `ReactNode` | – | Shown at the end of the row, such as a size, count or status. |
| `className` | `string` | – |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="tree-item-label"` | Target rows in CSS. |
| `data-value` | The item’s value. |
| `data-expanded` | Present when the branch is open. |
| `data-selected` | Present when the row is selected. |
| `data-checked` | Present when the row is checked. |
| `data-indeterminate` | Present when only some children are checked. |
| `data-disabled` | Present when the item is disabled. |

### TreeGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | – | Applied to the inner wrapper, so padding never fights the height animation. |

| Attribute | Description |
| --- | --- |
| `data-slot="tree-group"` | Target groups in CSS. |
| `data-open` | Present when the group is open. |
| `data-starting-style` | Present while the group animates in. |
| `data-ending-style` | Present while the group animates out. |
| `--collapsible-panel-height` | The group’s measured height, used for the height animation. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
