# Tree Navigation

> A collapsible nested navigation tree with WAI-ARIA arrow-key navigation, badges and controlled selection.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/tree-nav
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/tree-nav.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/tree-nav-demo.tsx"
"use client"

import * as React from "react"
import { BookOpen, Boxes, FileText, LifeBuoy, Rocket } from "lucide-react"

import { TreeNav, type TreeNavItem } from "@/components/tree-nav"

const items: TreeNavItem[] = [
  {
    id: "start",
    label: "Getting started",
    icon: <Rocket />,
    selectable: false,
    children: [
      { id: "intro", label: "Introduction" },
      { id: "install", label: "Installation" },
      {
        id: "structure",
        label: "Project structure",
        children: [
          { id: "folders", label: "Folders" },
          { id: "config", label: "Configuration" },
        ],
      },
    ],
  },
  {
    id: "components",
    label: "Components",
    icon: <Boxes />,
    selectable: false,
    children: [
      { id: "button", label: "Button" },
      {
        id: "navigation",
        label: "Navigation",
        badge: 3,
        children: [
          { id: "tabs", label: "Tabs" },
          { id: "menubar", label: "Menubar", badge: "New" },
          { id: "breadcrumb", label: "Breadcrumb" },
        ],
      },
      { id: "forms", label: "Forms" },
    ],
  },
  {
    id: "guides",
    label: "Guides",
    icon: <BookOpen />,
    selectable: false,
    children: [
      { id: "theming", label: "Theming" },
      { id: "a11y", label: "Accessibility" },
    ],
  },
  { id: "changelog", label: "Changelog", icon: <FileText />, badge: 2 },
  { id: "support", label: "Support", icon: <LifeBuoy /> },
]

const allParents = ["start", "components", "guides", "structure", "navigation"]

function findLabel(list: TreeNavItem[], id: string): string | undefined {
  for (const item of list) {
    if (item.id === id) return item.label
    const nested = item.children && findLabel(item.children, id)
    if (nested) return nested
  }
}

const smallButton = "h-8 rounded-lg border px-3 text-xs font-medium transition-colors hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"

export default function TreeNavDemo() {
  const [selected, setSelected] = React.useState("menubar")
  const [expanded, setExpanded] = React.useState(["components", "navigation"])

  return (
    <div className="grid w-full max-w-2xl grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-[16rem_minmax(0,1fr)]">
      <div className="flex min-w-0 flex-col gap-3 rounded-xl border bg-card p-2">
        <div className="flex items-center justify-between gap-2 px-1 pt-1">
          <p className="text-xs font-medium text-muted-foreground">Docs</p>
          <div className="flex gap-1.5">
            <button type="button" onClick={() => setExpanded(allParents)} className={smallButton}>
              Expand all
            </button>
            <button type="button" onClick={() => setExpanded([])} className={smallButton}>
              Collapse
            </button>
          </div>
        </div>
        <TreeNav
          aria-label="Documentation"
          items={items}
          value={selected}
          onValueChange={setSelected}
          expanded={expanded}
          onExpandedChange={setExpanded}
        />
      </div>
      <div className="flex min-w-0 flex-col justify-center gap-2 rounded-xl border border-dashed p-6">
        <p className="text-xs font-medium text-muted-foreground">Current page</p>
        <p data-testid="tree-current" className="truncate text-xl font-semibold">
          {findLabel(items, selected)}
        </p>
        <p className="text-sm text-muted-foreground">Use the arrow keys to move through the tree, Right or Left to expand and collapse.</p>
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/tree-nav.tsx"
"use client"

import * as React from "react"
import { ChevronRight } from "lucide-react"

import { cn } from "@/lib/utils"

export interface TreeNavItem {
  id: string
  label: string
  icon?: React.ReactNode
  badge?: string | number
  children?: TreeNavItem[]
  /** Set to false for section headers: activating them only expands or collapses. Defaults to true */
  selectable?: boolean
}

export interface TreeNavProps extends Omit<React.ComponentProps<"ul">, "defaultValue" | "onChange"> {
  items: TreeNavItem[]
  /** Selected item id (controlled) */
  value?: string
  defaultValue?: string
  onValueChange?: (id: string) => void
  /** Expanded item ids (controlled) */
  expanded?: string[]
  defaultExpanded?: string[]
  onExpandedChange?: (ids: string[]) => void
}

interface FlatNode {
  item: TreeNavItem
  parentId: string | null
}

function flatten(items: TreeNavItem[], expanded: Set<string>, parentId: string | null = null): FlatNode[] {
  return items.flatMap((item) => [
    { item, parentId },
    ...(item.children?.length && expanded.has(item.id) ? flatten(item.children, expanded, item.id) : []),
  ])
}

interface TreeContextValue {
  expanded: Set<string>
  selected: string | undefined
  tabStop: string | undefined
  toggle: (id: string) => void
  select: (item: TreeNavItem) => void
  setTabStop: (id: string) => void
}

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

function TreeBranch({ items, level }: { items: TreeNavItem[]; level: number }) {
  const ctx = React.useContext(TreeContext)
  if (!ctx) return null
  return (
    <>
      {items.map((item, index) => {
        const hasChildren = !!item.children?.length
        const isExpanded = hasChildren && ctx.expanded.has(item.id)
        const isSelected = ctx.selected === item.id
        return (
          <li
            key={item.id}
            role="treeitem"
            data-tree-id={item.id}
            aria-level={level}
            aria-setsize={items.length}
            aria-posinset={index + 1}
            aria-expanded={hasChildren ? isExpanded : undefined}
            aria-selected={item.selectable === false ? undefined : isSelected}
            tabIndex={ctx.tabStop === item.id ? 0 : -1}
            onFocus={(event) => {
              if (event.target === event.currentTarget) ctx.setTabStop(item.id)
            }}
            className="outline-none"
          >
            <div
              onClick={() => ctx.select(item)}
              className={cn(
                "relative flex h-9 cursor-pointer items-center gap-2 rounded-lg px-2 text-sm text-muted-foreground transition-colors select-none hover:bg-accent/60 hover:text-foreground [li:focus-visible>&]:ring-4 [li:focus-visible>&]:ring-ring/40",
                isSelected && "bg-accent font-medium text-foreground hover:bg-accent",
                !hasChildren && "pl-8"
              )}
            >
              {isSelected && <span aria-hidden className="absolute top-2 bottom-2 left-0 w-0.5 rounded-full bg-primary" />}
              {hasChildren && (
                <span
                  aria-hidden
                  onClick={(event) => {
                    event.stopPropagation()
                    ctx.toggle(item.id)
                  }}
                  className="-ml-1 flex size-5 shrink-0 items-center justify-center rounded-md hover:bg-foreground/10"
                >
                  <ChevronRight
                    className={cn("size-4 transition-transform duration-200 motion-reduce:transition-none", isExpanded && "rotate-90")}
                  />
                </span>
              )}
              {item.icon && <span className="flex shrink-0 items-center [&_svg]:size-4">{item.icon}</span>}
              <span className="min-w-0 flex-1 truncate">{item.label}</span>
              {item.badge !== undefined && (
                <span className="shrink-0 rounded-full bg-primary/10 px-1.5 text-[11px] leading-4.5 font-medium text-primary tabular-nums">
                  {item.badge}
                </span>
              )}
            </div>
            {hasChildren && (
              <div
                className={cn(
                  "grid transition-[grid-template-rows,visibility] duration-250 ease-out motion-reduce:transition-none",
                  isExpanded ? "visible grid-rows-[1fr]" : "invisible grid-rows-[0fr]"
                )}
              >
                <ul role="group" className="ml-4.5 min-h-0 overflow-hidden border-l pl-1.5">
                  <TreeBranch items={item.children ?? []} level={level + 1} />
                </ul>
              </div>
            )}
          </li>
        )
      })}
    </>
  )
}

export function TreeNav({
  items,
  value,
  defaultValue,
  onValueChange,
  expanded: expandedProp,
  defaultExpanded = [],
  onExpandedChange,
  className,
  onKeyDown,
  ...props
}: TreeNavProps) {
  const [innerSelected, setInnerSelected] = React.useState(defaultValue)
  const [innerExpanded, setInnerExpanded] = React.useState(defaultExpanded)
  const [focusId, setFocusId] = React.useState<string | undefined>(defaultValue)
  const root = React.useRef<HTMLUListElement>(null)
  const typed = React.useRef({ text: "", at: 0 })

  const selected = value ?? innerSelected
  const expandedIds = expandedProp ?? innerExpanded
  const expanded = React.useMemo(() => new Set(expandedIds), [expandedIds])
  const flat = React.useMemo(() => flatten(items, expanded), [items, expanded])
  // The roving tab stop must always point at a visible item
  const tabStop = flat.some((node) => node.item.id === focusId) ? focusId : flat[0]?.item.id

  const setExpanded = (next: string[]) => {
    if (expandedProp === undefined) setInnerExpanded(next)
    onExpandedChange?.(next)
  }

  const toggle = (id: string) => setExpanded(expanded.has(id) ? expandedIds.filter((x) => x !== id) : [...expandedIds, id])

  const select = (item: TreeNavItem) => {
    root.current?.querySelector<HTMLElement>(`[data-tree-id="${CSS.escape(item.id)}"]`)?.focus()
    if (item.children?.length && item.selectable === false) {
      toggle(item.id)
      return
    }
    if (value === undefined) setInnerSelected(item.id)
    onValueChange?.(item.id)
  }

  const focusNode = (id: string | undefined) => {
    if (!id) return
    root.current?.querySelector<HTMLElement>(`[data-tree-id="${CSS.escape(id)}"]`)?.focus()
  }

  const handleKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
    onKeyDown?.(event)
    if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return
    const id = (event.target as HTMLElement).closest<HTMLElement>("[data-tree-id]")?.dataset.treeId
    const index = flat.findIndex((node) => node.item.id === id)
    if (index < 0) return
    const node = flat[index]
    const hasChildren = !!node.item.children?.length
    const isExpanded = expanded.has(node.item.id)
    switch (event.key) {
      case "ArrowDown":
        focusNode(flat[Math.min(index + 1, flat.length - 1)].item.id)
        break
      case "ArrowUp":
        focusNode(flat[Math.max(index - 1, 0)].item.id)
        break
      case "Home":
        focusNode(flat[0].item.id)
        break
      case "End":
        focusNode(flat[flat.length - 1].item.id)
        break
      case "ArrowRight":
        if (hasChildren && !isExpanded) toggle(node.item.id)
        else if (hasChildren) focusNode(node.item.children?.[0]?.id)
        break
      case "ArrowLeft":
        if (hasChildren && isExpanded) toggle(node.item.id)
        else focusNode(node.parentId ?? undefined)
        break
      case "Enter":
      case " ":
        select(node.item)
        break
      default: {
        if (event.key.length !== 1 || !/\S/.test(event.key)) return
        const now = event.timeStamp
        typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : "") + event.key.toLowerCase(), at: now }
        const ordered = [...flat.slice(index + 1), ...flat.slice(0, index + 1)]
        focusNode(ordered.find((n) => n.item.label.toLowerCase().startsWith(typed.current.text))?.item.id)
        return
      }
    }
    event.preventDefault()
  }

  const context: TreeContextValue = { expanded, selected, tabStop, toggle, select, setTabStop: setFocusId }

  return (
    <TreeContext.Provider value={context}>
      <ul ref={root} role="tree" aria-label="Navigation" onKeyDown={handleKeyDown} className={cn("flex flex-col gap-0.5", className)} {...props}>
        <TreeBranch items={items} level={1} />
      </ul>
    </TreeContext.Provider>
  )
}
```
