# 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](/components/side-navigation.md).

```tsx
// 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

```tsx
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`.

```tsx
// 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>
  );
}
```

## Links and the current file

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.

```css
.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`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` (required) | `ReadonlyArray<Node>` | - | Nested tree data. Keep the reference stable between renders. |
| `expanded` | `ReadonlyArray<string>` | - | Ids of open folders (controlled). |
| `defaultExpanded` | `ReadonlyArray<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. |
| `currentId` | `string \| null` | `null` | Id of the current file. Its folders open when it changes, and it becomes the tree's Tab stop while focus is elsewhere. |
| `children` (required) | `(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`.
