Skip to content

File tree

A tree of nested folders and files, for browsing the contents of an artifact or repository. For links between the areas of an app, use Side navigation.

Code
demo.tsx
import {FileTree, type FileTreeItemState, type FileTreeNode} from '@earendil-works/elanor-react';
import {useCallback, useState, type MouseEvent} from 'react';

const items: FileTreeNode[] = [
  {
    id: 'docs',
    name: 'docs',
    children: [
      {
        id: 'docs/guides',
        name: 'guides',
        children: [{id: 'docs/guides/getting-started.md', name: 'getting-started.md'}],
      },
      {
        id: 'docs/a-very-long-file-name-that-does-not-fit-the-panel.md',
        name: 'a-very-long-file-name-that-does-not-fit-the-panel.md',
      },
    ],
  },
  {
    id: 'src',
    name: 'src',
    children: [
      {id: 'src/index.html', name: 'index.html'},
      {id: 'src/styles.css', name: 'styles.css'},
    ],
  },
  {id: 'README.md', name: 'README.md'},
];

export default function FileTreeDemo() {
  const [current, setCurrent] = useState('src/index.html');
  const renderItem = useCallback(({node, isFolder}: FileTreeItemState) => {
    const select = (event: MouseEvent) => {
      event.preventDefault();
      setCurrent(node.id);
    };
    return (
      <FileTree.Item render={isFolder ? undefined : <a href={`#${node.id}`} onClick={select} />}>
        <FileTree.ItemIcon />
        <FileTree.ItemLabel>{node.name}</FileTree.ItemLabel>
      </FileTree.Item>
    );
  }, []);
  return (
    <div style={{inlineSize: 260}}>
      <FileTree.Root aria-label="Files" items={items} currentId={current}>
        {renderItem}
      </FileTree.Root>
    </div>
  );
}

Usage

import {FileTree, type FileTreeItemState} from '@earendil-works/elanor-react';

const renderItem = ({node}: FileTreeItemState) => (
  <FileTree.Item>
    <FileTree.ItemIcon />
    <FileTree.ItemLabel>{node.name}</FileTree.ItemLabel>
  </FileTree.Item>
);

<FileTree.Root aria-label="Files" items={items}>
  {renderItem}
</FileTree.Root>;

items is the nested data: each node has a unique id and a name, and a folder has children, even if empty. Keep the items reference stable between renders. Root calls its children once per visible row with the row’s node, level, isFolder, expanded, and current; wrap the function in useCallback, because rows re-render when it changes. Name the tree with aria-label.

ItemIcon shows a chevron and a folder icon on folders, and a file icon on files; pass an icon as its children to replace the folder or file icon. Each level indents by one icon width, with a guide line under the parent’s chevron.

Open folders

Clicking a folder opens or closes it, instantly; only its chevron turns, with semantic.transition.movement.disclosure. Pass defaultExpanded with the ids of folders open at first, or control them with expanded and onExpandedChange.

reports
summary.md
Code
controlled.tsx
import {
  Button,
  FileTree,
  Stack,
  type FileTreeItemState,
  type FileTreeNode,
} from '@earendil-works/elanor-react';
import {useCallback, useState} from 'react';

const items: FileTreeNode[] = [
  {
    id: 'reports',
    name: 'reports',
    children: [
      {
        id: 'reports/2026',
        name: '2026',
        children: [
          {id: 'reports/2026/q1.pdf', name: 'q1.pdf'},
          {id: 'reports/2026/q2.pdf', name: 'q2.pdf'},
        ],
      },
      {id: 'reports/summary.md', name: 'summary.md'},
    ],
  },
  {
    id: 'data',
    name: 'data',
    children: [{id: 'data/results.csv', name: 'results.csv'}],
  },
];

const folderIds = ['reports', 'reports/2026', 'data'];

export default function FileTreeControlled() {
  const [expanded, setExpanded] = useState<string[]>(['reports']);
  const renderItem = useCallback(
    ({node}: FileTreeItemState) => (
      <FileTree.Item>
        <FileTree.ItemIcon />
        <FileTree.ItemLabel>{node.name}</FileTree.ItemLabel>
      </FileTree.Item>
    ),
    [],
  );
  return (
    <Stack gap="medium">
      <Stack direction="row" gap="small">
        <Button hierarchy="secondary" onClick={() => setExpanded(folderIds)}>
          Expand all
        </Button>
        <Button hierarchy="secondary" onClick={() => setExpanded([])}>
          Collapse all
        </Button>
      </Stack>
      <div style={{inlineSize: 260}}>
        <FileTree.Root
          aria-label="Files"
          items={items}
          expanded={expanded}
          onExpandedChange={setExpanded}
        >
          {renderItem}
        </FileTree.Root>
      </div>
    </Stack>
  );
}

Render a file’s Item as a link with render={<a href="…" />}, so it opens with a click, Enter, or the browser’s link menu. currentId marks that file with aria-current="page" and the semantic.color.background.fill.ghost.neutral.active fill. When it changes, the file’s folders open, without closing others, and the file scrolls into view. Without a controlled expanded, its folders are open from the first render, also on the server.

Names that don’t fit end with an ellipsis. Hovering or focusing the row with the keyboard shows the full name over it.

Keyboard

The tree is one Tab stop: the current file, or else the first row.

Key Action
Up, Down Previous or next row
Home, End First or last row
Right Opens a folder, or moves to its first child when open
Left Closes a folder, or moves to the parent
Enter Opens or closes a folder, or follows a file’s link
Space Opens or closes a folder
Letters Next row whose name starts with the typed text
Escape Hides the full name

Right and Left swap in right-to-left layouts.

Guide line

Change the guide with --file-tree-guide on Root: an image one indent step wide (semantic.dimension.size.xSmall plus semantic.dimension.borderWidth.default) and one row tall, repeated once per level.

.files {
  --file-tree-guide: url('./guide.svg');
}

Large trees

All visible rows are rendered, without virtualization. Moving between rows takes one frame at any size; first render takes about 45ms for 1,000 rows and 300ms for 10,000.

API

FileTree.Item

The row and treeitem. Pass render={<a href />} for files that navigate.

Renders <div>. Accepts its HTML attributes, ref, and render.

FileTree.ItemIcon

Chevron and folder icon for folders, file icon for files. Pass children to replace the icon.

Renders <span>. Accepts its HTML attributes and ref.

FileTree.ItemLabel

The name. Truncates with an ellipsis; indentation stays intact.

Renders <span>. Accepts its HTML attributes and ref.

FileTree.Root

PropTypeDefaultDescription
itemsrequiredReadonlyArray<Node>-Nested tree data. Keep the reference stable between renders.
expandedReadonlyArray<string>-Ids of open folders (controlled).
defaultExpandedReadonlyArray<string>[]Ids of folders open on first render (uncontrolled).
onExpandedChange((expanded: string[]) => void)-Called with the ids of open folders when a folder opens or closes.
currentIdstring | nullnullId of the current file. Its folders open when it changes, and it becomes the tree's Tab stop while focus is elsewhere.
childrenrequired(state: FileTreeItemState<Node>) => ReactNode-Renders one row. Memoize it (useCallback) for large trees: rows re-render when it changes.

Renders <div>. Also accepts its HTML attributes and ref.