Nested, expandable rows for files and hierarchies, with indent guides, connector lines, cascading checkboxes, typeahead and full keyboard support.
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>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/tree.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
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, }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 } - 4
Update the import paths to match your project setup.
import {
Tree,
TreeGroup,
TreeItem,
TreeItemLabel,
} from "@/components/ui/tree"<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.
Tree
└── TreeItem
├── TreeItemLabel
└── TreeGroup
└── TreeItem
└── TreeItemLabelIndent 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.
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.
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.
3 of 7 permissions granted
"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.
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.
1 selected
"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.
Selected: none
"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.
"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.
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.
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.
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.
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>
)
}| 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. |
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.
Branches open and close with the Base UI collapsible. <TreeItem /> and <TreeGroup /> accept the props of the parts they wrap.
| Prop | Type | Default |
|---|---|---|
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 | – |
checkboxesShow cascading checkboxes. Rows are checked instead of selected. | boolean | false |
checkedValuesChecked items. A branch in the list counts as checking its children. | string[] | – |
defaultCheckedValues | string[] | – |
onCheckedValuesChangeCalled with every checked leaf and every fully checked branch. | (values: string[]) => void | – |
disabled | boolean | false |
hiddenUntilFoundLet find-in-page open closed branches around a match. | boolean | true |
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. |
| Prop | Type | Default |
|---|---|---|
valueRequired. Unique within the tree. | string | – |
disabled | boolean | false |
onExpandedChangeCalled when this item opens or closes. | (expanded: boolean) => void | – |
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. |
| Prop | Type | Default |
|---|---|---|
iconShown before the label. | ReactNode | – |
expandedIconCrossfades in for icon while the branch is open. | ReactNode | – |
metaShown at the end of the row, such as a size, count or status. | ReactNode | – |
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. |
| Prop | Type | Default |
|---|---|---|
classNameApplied to the inner wrapper, so padding never fights the height animation. | string | – |
| 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. |