Tree view
Nodes in levels that open and close. Open branches flatten into one windowed list, so 10,000 nodes stay fast.
client - size not measured yet
Tree view
client - size not measured yetNodes in levels that open and close. Open branches flatten into one windowed list, so 10,000 nodes stay fast.
Try it
visibleRows
loadMoreThreshold
status
The code
tree-view.tsx
import { fileText } from 'topclassui/icons/file-text'
import { folder } from 'topclassui/icons/folder'
import { Svg } from 'topclassui/svg'
import { TreeView, type TreeViewDesignProps } from 'topclassui/tree-view'
<TreeView aria-label="Files" items={ITEMS} defaultExpanded={['src', 'app']} />items is plain data and a node's icon is an element, so a Server Component passes the tree as it is; there is no getKey.
Every state
4 statesLoading more
Loading
Nothing here yet
Import from the package
topclassui/tree-view
import { TreeView } from 'topclassui/tree-view'Install once:npm i topclassui@beta zod
Examples
- A node is
{ id, label, icon?, disabled?, children? }. Theidis the key; a node withchildren, even[], is a branch. - To load a branch's children, listen to
onExpandedChangeand pass them in throughitems. loadMoreasks for the next page of root nodes, once per page, whilehasMore. The tree never fetches.visibleRowssets how many rows show before the tree scrolls; left out, the--tc-list-rowsknob applies.virtualizewindows above 200 visible rows.statusmarks the tree busy whileloadingorloading-more, and says there is nothing whenempty.className,style,refand every other prop reach the element withrole="tree", not the box around it.
basic
basic.tsx
import { fileText } from 'topclassui/icons/file-text'
import { folder } from 'topclassui/icons/folder'
import { Svg } from 'topclassui/svg'
import { TreeView } from 'topclassui/tree-view'
const FOLDER = <Svg icon={folder} size="0.875rem" />
const FILE = <Svg icon={fileText} size="0.875rem" />
/** The drawn tree: two folders open. Each icon is an element, which crosses from a server page as it is. */
const ITEMS = [
{
id: 'src', label: 'src', icon: FOLDER, children: [
{
id: 'app', label: 'app', icon: FOLDER, children: [
{ id: 'layout', label: 'layout.tsx', icon: FILE },
{ id: 'page', label: 'page.tsx', icon: FILE },
],
},
{ id: 'components', label: 'components', icon: FOLDER, children: [{ id: 'button', label: 'button.tsx', icon: FILE }] },
{ id: 'lib', label: 'lib', icon: FOLDER, children: [] },
],
},
{ id: 'package', label: 'package.json', icon: FILE },
{ id: 'readme', label: 'README.md', icon: FILE },
]
export default function Demo() {
return <TreeView aria-label="Files" items={ITEMS} defaultExpanded={['src', 'app']} />
}
selected
Project
selected.tsx
import { TreeView } from 'topclassui/tree-view'
const ITEMS = [
{
id: 'app', label: 'app', children: [
{ id: 'layout', label: 'layout.tsx' },
{ id: 'page', label: 'page.tsx' },
],
},
{ id: 'docs', label: 'docs', children: [{ id: 'guide', label: 'guide.md' }, { id: 'faq', label: 'faq.md', disabled: true }] },
{ id: 'config', label: 'next.config.ts' },
]
/** One file selected inside an open folder, as the card draws it, beside a closed folder that holds a disabled file. */
export default function Demo() {
return (
<div className="grid gap-1">
<span id="tree-view-selected" className="text-sm font-medium text-body">Project</span>
<TreeView aria-labelledby="tree-view-selected" items={ITEMS} defaultValue="page" defaultExpanded={['app']} />
</div>
)
}
load-more
Repository
Pages asked for: 0, loaded: 1
load-more.tsx
'use client'
import { useCallback, useState } from 'react'
import { TreeView } from 'topclassui/tree-view'
const PAGE = 20
const LAST = 4
/** One page of the server's answer, made here so the demo needs no network: folders of two files each. */
function pageOf(page: number) {
return Array.from({ length: PAGE }, (_, index) => {
const n = page * PAGE + index + 1
return {
id: `d${String(n)}`,
label: `Folder ${String(n)}`,
children: [{ id: `d${String(n)}-a`, label: `a-${String(n)}.ts` }, { id: `d${String(n)}-b`, label: `b-${String(n)}.ts` }],
}
})
}
/** The tree asks and never fetches: loadMore is called once per page of root folders as the tree nears its end, and
* the page holds what has arrived. Four pages of twenty, each after a short wait. */
export default function Demo() {
const [items, setItems] = useState(() => pageOf(0))
const [asked, setAsked] = useState(0)
const [loading, setLoading] = useState(false)
const pages = items.length / PAGE
const loadMore = useCallback(() => {
setAsked((n) => n + 1)
setLoading(true)
return new Promise<void>((resolve) => {
setTimeout(() => {
setItems((current) => [...current, ...pageOf(current.length / PAGE)])
setLoading(false)
resolve()
}, 300)
})
}, [])
return (
<div className="grid gap-1">
<span id="tree-view-load-more" className="text-sm font-medium text-body">Repository</span>
<TreeView
aria-labelledby="tree-view-load-more"
items={items}
hasMore={pages < LAST}
loadMore={loadMore}
status={loading ? 'loading-more' : 'idle'}
/>
<p data-testid="asked" className="text-xs text-muted">{`Pages asked for: ${String(asked)}, loaded: ${String(pages)}`}</p>
</div>
)
}
status
Loading
Nothing here yet
status.tsx
import { TreeView } from 'topclassui/tree-view'
/** loading: the tree is busy and says Loading until its first nodes arrive. empty: it says there is nothing. Both
* texts sit under the tree, in a status line, since a tree holds tree items only. */
export default function Demo() {
return (
<div className="grid gap-3">
<TreeView aria-label="Files, loading" status="loading" items={[]} />
<TreeView aria-label="Files, empty" status="empty" items={[]} />
</div>
)
}
ten-thousand
ten-thousand.tsx
import { TreeView } from 'topclassui/tree-view'
/** Ten folders of a thousand files, all open: 10,010 visible rows, built once at module scope so the array is stable. */
const items = Array.from({ length: 10 }, (_, folder) => ({
id: `f${String(folder + 1)}`,
label: `Folder ${String(folder + 1)}`,
children: Array.from({ length: 1000 }, (__, file) => ({
id: `f${String(folder + 1)}-${String(file + 1)}`,
label: `File ${String(folder * 1000 + file + 1)}.ts`,
})),
}))
const open = items.map((node) => node.id)
/** Windowed: a few dozen rows are mounted whatever the scroll, and the keys still reach every one of them. */
export default function Demo() {
return <TreeView aria-label="Ten thousand files" items={items} defaultExpanded={open} visibleRows={12} />
}
long-content
long-content.tsx
import { TreeView } from 'topclassui/tree-view'
const WORD = 'Supercalifragilistic'.repeat(15)
/** Ten levels deep, each folder the only child of the one above. */
function nest(depth: number): { id: string, label: string, children?: { id: string, label: string }[] } {
const id = `level-${String(depth)}`
if (depth === 10) return { id, label: `Level ${String(depth)}, ${WORD}` }
return { id, label: `Level ${String(depth)}`, children: [nest(depth + 1)] }
}
const sixty = Array.from({ length: 60 }, (_, index) => ({ id: `item-${String(index + 1)}`, label: `Item ${String(index + 1)}` }))
const items = [
{ id: 'word', label: WORD },
{ id: 'mixed', label: 'تقرير report 2026 النهائي final version with a long name that goes on' },
nest(1),
{ id: 'sixty', label: 'Sixty items', children: sixty },
]
const open = ['sixty', ...Array.from({ length: 9 }, (_, index) => `level-${String(index + 1)}`)]
/** Every hostile case: a 300-character word, mixed directions, ten levels deep and sixty siblings. Nothing widens the page. */
export default function Demo() {
return <TreeView aria-label="Every hostile case" items={items} defaultExpanded={open} defaultValue="word" />
}
In Arabic, right to left
rtl.tsx
import { fileText } from 'topclassui/icons/file-text'
import { folder } from 'topclassui/icons/folder'
import { TopClassProvider } from 'topclassui/provider'
import { Svg } from 'topclassui/svg'
import { TreeView } from 'topclassui/tree-view'
const FOLDER = <Svg icon={folder} size="0.875rem" />
const FILE = <Svg icon={fileText} size="0.875rem" />
const ITEMS = [
{
id: 'docs', label: 'المستندات', icon: FOLDER, children: [
{ id: 'report', label: 'التقرير السنوي.pdf', icon: FILE },
{ id: 'budget', label: 'الميزانية.xlsx', icon: FILE },
],
},
{ id: 'photos', label: 'الصور', icon: FOLDER, children: [{ id: 'trip', label: 'رحلة العقبة.jpg', icon: FILE }] },
{ id: 'notes', label: 'ملاحظات.txt', icon: FILE },
]
/** Arabic, inside TopClassProvider as the root layout would mount it: the indent is on the right, the closed caret
* points left, and ArrowLeft opens a branch. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar">
<TreeView aria-label="الملفات" items={ITEMS} defaultValue="report" defaultExpanded={['docs']} />
</div>
</TopClassProvider>
)
}
Props
Keyboard and accessibility
- The tree is the one tab stop and keeps the focus;
aria-activedescendantnames the row the keys are on, so windowing never drops the focus. - A tree draws no name of its own:
aria-labeloraria-labelledbyis required by the types. - Each row carries its level, its position among its siblings and whether it is open, so a screen reader says where it is.
Right to left
The indent is on the right and a closed caret points left. ArrowLeft opens a branch and ArrowRight closes it.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.