Skip to content

Tree ​

The rows of a FileTree, as parts, for nodes whose identity is not a file path: connections, schemas, objects in a store. The parts draw; selection, expansion, loading and keyboard navigation are yours.

tsx
import { Tree } from '@adecore/ui';
tsx
import { useState } from 'react';
import { Database, Table } from 'lucide-react';
import { Icon, Tree } from '@adecore/ui';

export default function TreeDemo() {
    const [expanded, setExpanded] = useState(true);
    const [selected, setSelected] = useState('customers');

    return (
        <Tree.Root aria-label="Database objects" className="w-72 py-2">
            <Tree.Row selected={selected === 'store'} aria-expanded={expanded} tabIndex={0} onClick={() => setSelected('store')}>
                <Tree.Chevron expanded={expanded} onExpandedChange={setExpanded} />
                <Icon icon={Database} size={16} className="text-text-muted" />
                <Tree.Label>Store</Tree.Label>
            </Tree.Row>
            {expanded &&
                ['customers', 'orders'].map((name) => (
                    <Tree.Row key={name} level={2} selected={selected === name} tabIndex={-1} onClick={() => setSelected(name)}>
                        <Tree.ChevronSlot />
                        <Icon icon={Table} size={16} className="text-text-muted" />
                        <Tree.Label>{name}</Tree.Label>
                    </Tree.Row>
                ))}
        </Tree.Root>
    );
}

Parts ​

PartWhat it is
Tree.RootThe container, with role="tree". Name it with aria-label or aria-labelledby. overflow says what a row wider than the tree does.
Tree.RowOne role="treeitem", with its level, selected state, indentation guides and the accent outline under the keyboard.
Tree.ChevronThe button that opens or closes a branch without selecting its row.
Tree.ChevronSlotThe empty space of a chevron, so a leaf lines up with the branches beside it.
Tree.LabelThe name, cut off at the end.
Tree.DecorationContent at the end of the row, such as a count.
Tree.ControlWraps an interactive control so its events never reach the row.
Tree.CheckboxA Checkbox inside a Tree.Control, with real mixed and disabled states.

Every part takes className, ref and Base UI's render prop, except Tree.Checkbox. Keep a row that holds controls a <div>: a row rendered as a button would nest other buttons inside it.

Rows wider than the tree ​

By default Tree.Label cuts a long name off at the end. With overflow="scroll" on Tree.Root, rows stay whole and slide sideways together on a sideways scroll, as in a FileTree. A thin bar at the bottom edge shows the shift, so give the tree the height it may fill and overflow-y-auto, and leave its bottom padding off.

level starts at 1; each level indents and draws a guide for every ancestor. joinedStart and joinedEnd square the corners between adjacent selected rows; compute them from the visible rows.

Keyboard and focus ​

A row handles no key and is no tab stop by itself. This tree keeps one tab stop that the arrow keys move. Left and right close and open the branch, and Enter activates a leaf:

tsx
import { useRef, useState, type KeyboardEvent } from 'react';
import { Tree } from '@adecore/ui';

export function ObjectTree() {
    const [expanded, setExpanded] = useState(true);
    const [selected, setSelected] = useState('group');
    const [focused, setFocused] = useState('group');
    const [opened, setOpened] = useState<string | null>(null);
    const elements = useRef(new Map<string, HTMLDivElement>());
    const rows = expanded ? ['group', 'customers', 'orders'] : ['group'];

    const focus = (id: string): void => {
        setFocused(id);
        elements.current.get(id)?.focus();
    };

    const activate = (id: string): void => {
        setSelected(id);

        if (id === 'group') {
            setExpanded((value) => !value);
        } else {
            setOpened(id);
        }
    };

    const onKey = (event: KeyboardEvent<HTMLDivElement>, id: string): void => {
        if (event.target !== event.currentTarget || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
            return;
        }

        const index = rows.indexOf(id);

        switch (event.key) {
            case 'ArrowDown':
                focus(rows[Math.min(index + 1, rows.length - 1)]!);
                break;
            case 'ArrowUp':
                focus(rows[Math.max(index - 1, 0)]!);
                break;
            case 'Home':
                focus(rows[0]!);
                break;
            case 'End':
                focus(rows[rows.length - 1]!);
                break;
            case 'ArrowRight':
                if (id === 'group') {
                    if (expanded) {
                        focus('customers');
                    } else {
                        setExpanded(true);
                    }
                }
                break;
            case 'ArrowLeft':
                if (id === 'group') {
                    setExpanded(false);
                } else {
                    focus('group');
                }
                break;
            case 'Enter':
                activate(id);
                break;
            case ' ':
                setSelected(id);
                break;
            default:
                return;
        }

        event.preventDefault();
    };

    return (
        <div>
            <Tree.Root aria-label="Data objects">
                {rows.map((id, index) => (
                    <Tree.Row
                        key={id}
                        ref={(element) => {
                            if (element) {
                                elements.current.set(id, element);
                            } else {
                                elements.current.delete(id);
                            }
                        }}
                        level={id === 'group' ? 1 : 2}
                        selected={selected === id}
                        tabIndex={focused === id ? 0 : -1}
                        aria-expanded={id === 'group' ? expanded : undefined}
                        aria-setsize={id === 'group' ? 1 : 2}
                        aria-posinset={id === 'group' ? 1 : index}
                        onFocus={() => setFocused(id)}
                        onClick={() => {
                            focus(id);
                            activate(id);
                        }}
                        onKeyDown={(event) => onKey(event, id)}
                    >
                        {id === 'group' ? (
                            <Tree.Chevron
                                expanded={expanded}
                                onExpandedChange={(value) => {
                                    if (!value && focused !== 'group') {
                                        focus('group');
                                    }

                                    setExpanded(value);
                                }}
                            />
                        ) : (
                            <Tree.ChevronSlot />
                        )}
                        <Tree.Label>{id === 'group' ? 'Store' : id}</Tree.Label>
                    </Tree.Row>
                ))}
            </Tree.Root>
            {opened !== null && <p>Opened {opened}</p>}
        </div>
    );
}

This example has no selection with a modifier, typeahead or virtualization. A larger tree draws a row before it focuses it. When a branch closes or a node disappears, move the focus to a parent or neighbor first.

The reference lists every prop and covers loading, controls and menus. DatabaseExplorer is a complete tree built on these parts.

Licenses and third-party notices are listed in each package.