- src
- components
- button.tsx
- card.tsx
- tree-view.tsx
- lib
- styles
- README.md
- package.json
import { FileCodeIcon, FileTextIcon, FolderIcon } from "lucide-react"
import { TreeItem, TreeView } from "@/components/ui/tree-view"
export default function TreeViewDemo() {
return (
<TreeView
aria-label="Project files"
className="w-full max-w-72"
defaultExpanded={["src", "components"]}
defaultSelected="button"
>
<TreeItem value="src" label="src" icon={<FolderIcon />}>
<TreeItem value="components" label="components" icon={<FolderIcon />}>
<TreeItem value="button" label="button.tsx" icon={<FileCodeIcon />} />
<TreeItem value="card" label="card.tsx" icon={<FileCodeIcon />} />
<TreeItem value="tree-view" label="tree-view.tsx" icon={<FileCodeIcon />} />
</TreeItem>
<TreeItem value="lib" label="lib" icon={<FolderIcon />}>
<TreeItem value="utils" label="utils.ts" icon={<FileCodeIcon />} />
</TreeItem>
<TreeItem value="styles" label="styles" icon={<FolderIcon />}>
<TreeItem value="theme" label="theme.css" icon={<FileTextIcon />} />
</TreeItem>
</TreeItem>
<TreeItem value="readme" label="README.md" icon={<FileTextIcon />} />
<TreeItem value="package" label="package.json" icon={<FileTextIcon />} disabled />
</TreeView>
)
}Installation
npx logic2b@latest add tree-viewWorking with an AI assistant? Copy this prompt into Claude Code, Cursor or Copilot — it carries the install steps, a no-CLI fallback and a verification checklist.
Add the "tree-view" item from the logic2b ui registry (https://ui.logic2b.com) to this project. Follow every step and verify at the end. ## Steps 1. If the project has no `components.json` yet, set up logic2b ui first: ```bash npx logic2b@latest init ``` 2. Install the item (the CLI resolves registry dependencies automatically): ```bash npx logic2b@latest add tree-view ``` 3. Install any npm dependencies the command prints. ## If the CLI is not available Do it manually from the registry payload: 1. Fetch https://ui.logic2b.com/r/tree-view.json. 2. Write every entry in `files[]` to its mapped project path (see below). 3. Repeat (recursively) for each name in `registryDependencies`. 4. Install every package listed in `dependencies` across those payloads. ## Registry reference - Index of every item: https://ui.logic2b.com/r/index.json - Item payload (full source): https://ui.logic2b.com/r/<name>.json - Usage examples per item: https://ui.logic2b.com/r/demos/index.json → https://ui.logic2b.com/r/demos/<demo>.json - Docs index for agents: https://ui.logic2b.com/llms.txt (full docs: https://ui.logic2b.com/llms-full.txt) - Every docs page is Markdown when you append `.md` to its URL. Registry file paths map to project paths like this: - `ui/*` → `@/components/ui/*` - `blocks/<name>/*` → `@/components/*` - `charts/*` → `@/components/charts/*` - `hooks/*` → `@/hooks/*` - `lib/utils.ts` → `@/lib/utils.ts` - `theme.css` → next to the project's Tailwind entry stylesheet ## Conventions (do not break these) - Tailwind CSS v4 (CSS-first config; no tailwind.config.js needed) and React 19. - All colors go through semantic tokens (`bg-primary`, `text-muted-foreground`, `border-border`, …). Never hardcode hex/oklch values in components. - Dark mode is class-based: toggle `.dark` on `<html>`. - Icons come from lucide-react. ## Verify before finishing 1. The dev server starts and the app renders without console errors. 2. TypeScript compiles (`tsc --noEmit` or the framework's check). 3. Components use the theme: a `<Button>` shows the primary token color, and toggling `.dark` on `<html>` switches the palette.
Install the dependencies:
npm install lucide-reactCopy into ui/tree-view.tsx:
"use client"
import * as React from "react"
import { ChevronRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* Tree view — WAI-ARIA tree pattern, hand-rolled (no Radix primitive exists).
* Branch/leaf is inferred from children; expansion and selection are
* controllable. Keyboard: arrows move through visible items, right/left
* expand/collapse (left also jumps to the parent), Home/End, Enter/Space
* select.
*/
function useControllableState<T>(
prop: T | undefined,
defaultProp: T,
onChange?: (value: T) => void
) {
const [state, setState] = React.useState(defaultProp)
const isControlled = prop !== undefined
const value = isControlled ? prop : state
const set = React.useCallback(
(next: T) => {
if (!isControlled) setState(next)
onChange?.(next)
},
[isControlled, onChange]
)
return [value, set] as const
}
interface TreeViewContextValue {
expanded: string[]
toggle: (value: string) => void
selected: string | null
select: (value: string) => void
}
const TreeViewContext = React.createContext<TreeViewContextValue | null>(null)
const TreeLevelContext = React.createContext(1)
function useTreeView() {
const ctx = React.useContext(TreeViewContext)
if (!ctx) throw new Error("TreeItem must be used within a TreeView.")
return ctx
}
const visibleItems = (tree: HTMLElement) =>
Array.from(
tree.querySelectorAll<HTMLElement>(
'[role="treeitem"]:not([aria-disabled="true"])'
)
)
function TreeView({
className,
children,
expanded: expandedProp,
defaultExpanded,
onExpandedChange,
selected: selectedProp,
defaultSelected,
onSelectedChange,
...props
}: Omit<React.ComponentProps<"ul">, "onSelect"> & {
expanded?: string[]
defaultExpanded?: string[]
onExpandedChange?: (expanded: string[]) => void
selected?: string | null
defaultSelected?: string | null
onSelectedChange?: (selected: string | null) => void
}) {
const ref = React.useRef<HTMLUListElement>(null)
const [hasFocusWithin, setHasFocusWithin] = React.useState(false)
const [expanded, setExpanded] = useControllableState(
expandedProp,
defaultExpanded ?? [],
onExpandedChange
)
const [selected, setSelected] = useControllableState<string | null>(
selectedProp,
defaultSelected ?? null,
onSelectedChange
)
const toggle = React.useCallback(
(value: string) =>
setExpanded(
expanded.includes(value)
? expanded.filter((v) => v !== value)
: [...expanded, value]
),
[expanded, setExpanded]
)
const onKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
const item = (event.target as HTMLElement).closest<HTMLElement>(
'[role="treeitem"]'
)
const tree = ref.current
if (!item || !tree) return
const items = visibleItems(tree)
const index = items.indexOf(item)
const isBranch = item.hasAttribute("aria-expanded")
const isExpanded = item.getAttribute("aria-expanded") === "true"
const value = item.dataset.value ?? ""
const focus = (el?: HTMLElement) => el?.focus()
switch (event.key) {
case "ArrowDown":
focus(items[index + 1])
break
case "ArrowUp":
focus(items[index - 1])
break
case "ArrowRight":
if (isBranch && !isExpanded) toggle(value)
else if (isBranch) focus(items[index + 1])
break
case "ArrowLeft":
if (isBranch && isExpanded) toggle(value)
else focus(item.parentElement?.closest<HTMLElement>('[role="treeitem"]') ?? undefined)
break
case "Home":
focus(items[0])
break
case "End":
focus(items[items.length - 1])
break
case "Enter":
case " ":
setSelected(value)
if (isBranch) toggle(value)
break
default:
return
}
event.preventDefault()
}
return (
<TreeViewContext.Provider value={{ expanded, toggle, selected, select: setSelected }}>
<ul
ref={ref}
role="tree"
data-slot="tree-view"
tabIndex={hasFocusWithin ? -1 : 0}
className={cn(
"flex flex-col gap-px text-sm outline-none select-none",
className
)}
onFocus={(event) => {
setHasFocusWithin(true)
if (event.target !== ref.current) return
const tree = ref.current
const target =
tree.querySelector<HTMLElement>('[role="treeitem"][aria-selected="true"]') ??
visibleItems(tree)[0]
target?.focus()
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setHasFocusWithin(false)
}
}}
onKeyDown={onKeyDown}
{...props}
>
{children}
</ul>
</TreeViewContext.Provider>
)
}
function TreeItem({
className,
children,
value,
label,
icon,
disabled,
...props
}: Omit<React.ComponentProps<"li">, "onSelect"> & {
value: string
label: React.ReactNode
icon?: React.ReactNode
disabled?: boolean
}) {
const { expanded, toggle, selected, select } = useTreeView()
const level = React.useContext(TreeLevelContext)
const isBranch = React.Children.count(children) > 0
const isExpanded = isBranch && expanded.includes(value)
const isSelected = selected === value
return (
<li
role="treeitem"
data-slot="tree-item"
data-value={value}
aria-level={level}
aria-selected={isSelected}
aria-expanded={isBranch ? isExpanded : undefined}
aria-disabled={disabled || undefined}
tabIndex={-1}
className={cn("group/tree-item outline-none", className)}
{...props}
>
<div
data-slot="tree-item-row"
className={cn(
"hover:bg-accent hover:text-accent-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors",
"group-focus-visible/tree-item:border-ring group-focus-visible/tree-item:ring-ring/50 group-focus-visible/tree-item:ring-[3px]",
isSelected && "bg-accent text-accent-foreground",
disabled && "pointer-events-none opacity-50"
)}
style={{ paddingLeft: `calc(${level - 1} * 1rem + 0.5rem)` }}
onClick={() => {
if (disabled) return
select(value)
if (isBranch) toggle(value)
}}
>
{isBranch ? (
<ChevronRightIcon
data-slot="tree-item-chevron"
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform duration-200",
isExpanded && "rotate-90"
)}
onClick={(event) => {
event.stopPropagation()
if (!disabled) toggle(value)
}}
/>
) : (
<span className="size-4 shrink-0" />
)}
{icon && (
<span
data-slot="tree-item-icon"
className="text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0"
>
{icon}
</span>
)}
<span data-slot="tree-item-label" className="truncate">
{label}
</span>
</div>
{isExpanded && (
<ul role="group" data-slot="tree-item-group" className="flex flex-col gap-px">
<TreeLevelContext.Provider value={level + 1}>
{children}
</TreeLevelContext.Provider>
</ul>
)}
</li>
)
}
export { TreeView, TreeItem }Usage
import { TreeItem, TreeView } from "@/components/ui/tree-view"
<TreeView aria-label="Files" defaultExpanded={["src"]}>
<TreeItem value="src" label="src">
<TreeItem value="button" label="button.tsx" />
<TreeItem value="card" label="card.tsx" />
</TreeItem>
<TreeItem value="readme" label="README.md" />
</TreeView>
Nesting TreeItem children turns an item into a branch — the chevron,
aria-expanded state and indentation are derived automatically.
API
| Prop | Type | Default |
|---|---|---|
expanded / defaultExpanded (TreeView) |
string[] — controlled/uncontrolled expanded branch values |
[] |
onExpandedChange (TreeView) |
(expanded: string[]) => void |
— |
selected / defaultSelected (TreeView) |
string | null — controlled/uncontrolled selected value |
null |
onSelectedChange (TreeView) |
(selected: string | null) => void |
— |
value (TreeItem) |
string — unique item id (required) |
— |
label (TreeItem) |
ReactNode — the row text (required) |
— |
icon (TreeItem) |
ReactNode — rendered before the label |
— |
disabled (TreeItem) |
boolean — skipped by pointer and keyboard |
false |
Examples
Controlled
Drive expansion and selection externally — expand/collapse everything from the outside and react to the selected value.
- workspace
- apps
- packages
Selected: none
"use client"
import * as React from "react"
import { FolderIcon, LayersIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { TreeItem, TreeView } from "@/components/ui/tree-view"
const ALL = ["workspace", "apps", "packages"]
export default function TreeViewControlledDemo() {
const [expanded, setExpanded] = React.useState<string[]>(["workspace"])
const [selected, setSelected] = React.useState<string | null>(null)
return (
<div className="flex w-full max-w-72 flex-col gap-4">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setExpanded(ALL)}>
Expand all
</Button>
<Button variant="outline" size="sm" onClick={() => setExpanded([])}>
Collapse all
</Button>
</div>
<TreeView
aria-label="Monorepo"
expanded={expanded}
onExpandedChange={setExpanded}
selected={selected}
onSelectedChange={setSelected}
>
<TreeItem value="workspace" label="workspace" icon={<LayersIcon />}>
<TreeItem value="apps" label="apps" icon={<FolderIcon />}>
<TreeItem value="web" label="web" />
<TreeItem value="docs" label="docs" />
</TreeItem>
<TreeItem value="packages" label="packages" icon={<FolderIcon />}>
<TreeItem value="ui" label="ui" />
<TreeItem value="cli" label="cli" />
</TreeItem>
</TreeItem>
</TreeView>
<p className="text-muted-foreground text-sm">
Selected: <span className="font-medium">{selected ?? "none"}</span>
</p>
</div>
)
}Accessibility
- Follows the WAI-ARIA Tree View pattern.
- Arrow Up / Arrow Down move through visible items; Home / End jump to the first/last.
- Arrow Right expands a collapsed branch (then steps into it); Arrow Left collapses an expanded branch or moves to the parent.
- Enter or Space selects the focused item (and toggles branches).
- Items expose
aria-level,aria-expandedandaria-selected; disabled items arearia-disabledand skipped.