Trees is in beta. Start from the public React, vanilla, and SSR entry points on this page. Expect polish and small API shifts between beta releases.
Trees keeps one path-first model across React, vanilla, and SSR hydration. Selection, focus, search, rename, drag and drop, Git status, and row annotations all work in terms of canonical paths.
These docs stay guide-first. Pick your runtime, shape tree data before it reaches the UI, and then add search, item actions, styling, icons, row signals, or SSR as needed.
@pierre/trees exposes one path-first model and two primary runtime entries: a
thin React layer in @pierre/trees/react and the
vanilla class in @pierre/trees.
Treat canonical path strings as the public identity for every item in the tree.
For example, src/components/Button.tsx is not just a label on screen. It is
the value you read from selection state, the path you focus programmatically,
and the target you rename or move later.
For the shared vocabulary behind that rule, jump to Shared concepts.
Pick the React entry point when the surrounding UI already lives in React. Jump to the getting started with React guide for more info.
1234567import { FileTree, useFileTree } from '@pierre/trees/react';
export function ProjectTree({ paths }: { paths: readonly string[] }) { const { model } = useFileTree({ paths, search: true });
return <FileTree model={model} className="h-96 rounded-lg border" />;}Pick the vanilla class when your app is not React-based, or when another framework will own lifecycle around an imperative model. Jump to the getting started with vanilla JS for more.
1234567891011import { FileTree } from '@pierre/trees';
const fileTree = new FileTree({ paths: ['README.md', 'src/index.ts', 'src/components/Button.tsx'], search: true,});
const container = document.getElementById('project-tree');if (container instanceof HTMLElement) { fileTree.render({ fileTreeContainer: container });}Both runtimes consume the same tree data. Small examples can start with raw
paths, but real application trees should move to prepared input before the
client pays that shaping work on every load. Regardless of your runtime being
React or vanilla JS, be sure to read
Shape tree data for fast rendering after
your runtime quickstart.
Use the React entry point when your surrounding UI already lives in React. The hook creates one stable tree model, and the component mounts that model into the host element.
@pierre/treesUse the package root for the vanilla runtime, and the /react entry point for
the React wrapper.
123bun add @pierre/trees# npm: npm install @pierre/trees# pnpm: pnpm add @pierre/treesuseFileTree(...)useFileTree(...) from @pierre/trees/react creates the model exactly once for
the component lifetime. Later option changes are not a controlled update path.
If the tree data or runtime behavior changes after mount, update the model
through methods such as resetPaths(...), setComposition(...),
setGitStatus(...), or setIcons(...).
For small trees, you can pass raw paths. For scalable trees, prefer
preparedInput produced on the server or another non-UI boundary.
12345678910111213141516171819202122import { FileTree, useFileTree } from '@pierre/trees/react';import type { FileTreePreparedInput } from '@pierre/trees';
interface ProjectTreeProps { preparedInput: FileTreePreparedInput;}
export function ProjectTree({ preparedInput }: ProjectTreeProps) { const { model } = useFileTree({ preparedInput, search: true, initialExpandedPaths: ['src', 'src/components'], });
return ( <FileTree model={model} className="rounded-lg border" style={{ height: '320px' }} /> );}If you are still deciding how to shape that input, read Shape tree data for fast rendering after this quickstart.
<FileTree model={model} /><FileTree /> is a thin React wrapper over the model. It mounts the tree into
the host element, forwards normal host props such as className and style,
and hydrates existing server output when you pass preloadedData later.
Keep the mental model simple: the model owns the tree state, and the React component renders it.
React code reads snapshots from the model through selector hooks, and writes back through model methods.
12345678910111213141516171819202122232425262728import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelection,} from '@pierre/trees/react';
export function SearchableTree({ paths }: { paths: readonly string[] }) { const { model } = useFileTree({ paths, fileTreeSearchMode: 'hide-non-matches', search: true, }); const selectedPaths = useFileTreeSelection(model); const search = useFileTreeSearch(model);
return ( <div className="space-y-3"> <input value={search.value} onChange={(event) => search.setValue(event.target.value)} placeholder="Search files" /> <p>{selectedPaths.length} item(s) selected.</p> <FileTree model={model} className="rounded-lg border" /> </div> );}Use useFileTreeSelector(model, selector, equality?) when sibling UI needs a
custom derived snapshot without rerendering on unrelated tree changes. For the
shared interaction vocabulary, read
Navigate selection, focus, and search
and React API.
paths input only when the tree is smallRaw paths is the low-ceremony path for demos, tests, and very small static
trees. It is not the scalable default. Once the tree becomes expensive to shape
or sort on the client, move that work out of the UI.
The recommended scale path is:
preparedInput into useFileTree(...).If the server already knows the final order,
preparePresortedFileTreeInput(...) is the highest-performance prepared-input
variant because the client can skip both shaping and extra sorting work.
Hydration layers on top of the same model-first React story. The client still
calls useFileTree(...), and the React wrapper still renders the same model.
The only difference is that the tree starts from preloaded server output.
When you need that flow, continue with SSR and SSR API.
Use the vanilla runtime when your app is not React-based, or when another
framework will own lifecycle around an imperative tree model.
new FileTree(...) creates the model, and render(...) or hydrate(...)
attaches it to the DOM.
@pierre/trees123bun add @pierre/trees# npm: npm install @pierre/trees# pnpm: pnpm add @pierre/treesnew FileTree(...)The class instance is both the runtime entry point and the state surface. For
small trees, you can pass raw paths. For scalable trees, prefer
preparedInput produced outside the UI.
12345678910111213141516import { FileTree, type FileTreePreparedInput } from '@pierre/trees';
export function mountProjectTree( container: HTMLElement, preparedInput: FileTreePreparedInput) { const fileTree = new FileTree({ preparedInput, search: true, initialExpandedPaths: ['src', 'src/components'], });
container.style.height = '320px'; fileTree.render({ fileTreeContainer: container }); return fileTree;}render({ fileTreeContainer }) mounts the model into an existing host element.
If you want the runtime to create the host for you, use
render({ containerWrapper }) instead.
Keep the boundary clear: the model owns tree state, and the mounted host only renders that state. Do not scrape the DOM to find the selected item or current focus. Read and update those values through the model.
The instance gives you direct read methods, item handles, and imperative controls.
1234567891011121314const fileTree = new FileTree({ paths: ['README.md', 'src/index.ts', 'src/components/Button.tsx'], search: true,});
fileTree.render({ fileTreeContainer: container });fileTree.focusPath('src/index.ts');fileTree.openSearch('button');
const selectedPaths = fileTree.getSelectedPaths();const matchingPaths = fileTree.getSearchMatchingPaths();const focusedPath = fileTree.getFocusedPath();const buttonItem = fileTree.getItem('src/components/Button.tsx');buttonItem?.select();When surrounding data changes, call explicit model methods such as
resetPaths(...), setComposition(...), setGitStatus(...), or
setIcons(...) instead of reconstructing the model in place.
paths input only when the tree is smallRaw paths is the low-ceremony path for demos, tests, and small static trees.
It is not the recommended setup for repo-scale or workspace-scale trees.
The recommended scale path is:
new FileTree({ preparedInput, ... }).If the server or indexer already knows the final order,
preparePresortedFileTreeInput(...) is the better fit because the client can
skip extra sorting work.
Hydration layers on top of the same class-first runtime. The client still
creates new FileTree(...), but instead of rendering fresh markup it attaches
that model to server-rendered tree output with hydrate({ fileTreeContainer }).
When you need that flow, continue with SSR and SSR API.
If you wrap the vanilla model in another framework, keep the ownership boundary the same:
FileTree instance from that framework's lifecycleThat keeps React as the only first-class wrapper surface without changing how the underlying model works.
Shape the tree before it reaches the UI when the dataset is large enough for client-side preparation to matter. Trees treats prepared input as a first-class public concept, not as an internal optimization trick.
prepareFileTreeInput(...) lets the client skip repeated tree-shaping work. The
best place to do that preparation is usually the server, a loader, or another
non-UI boundary that already has the full path list.
123456789import { prepareFileTreeInput } from '@pierre/trees';
export async function loadProjectTreeInput(projectId: string) { const paths = await fetchProjectPaths(projectId);
return prepareFileTreeInput(paths, { flattenEmptyDirectories: true, });}The client still works with the same path-first model. Only the expensive preparation step moves earlier.
preparedInput into the runtimeBoth primary runtimes consume the same prepared payload shape.
1234567891011import { FileTree, useFileTree } from '@pierre/trees/react';import type { FileTreePreparedInput } from '@pierre/trees';
export function ReactTree({ preparedInput,}: { preparedInput: FileTreePreparedInput;}) { const { model } = useFileTree({ preparedInput }); return <FileTree model={model} style={{ height: '320px' }} />;}1234567891011import { FileTree, type FileTreePreparedInput } from '@pierre/trees';
export function mountVanillaTree( container: HTMLElement, preparedInput: FileTreePreparedInput) { const fileTree = new FileTree({ preparedInput }); container.style.height = '320px'; fileTree.render({ fileTreeContainer: container }); return fileTree;}paths input only for small treesRaw paths is still the right call for small demos, tests, and very small
static trees.
123const fileTree = new FileTree({ paths: ['README.md', 'src/index.ts', 'src/components/Button.tsx'],});That is the easy starting path, not the scale-oriented default. When the tree grows, move the preparation work out of the client.
If your server or indexer already knows the final order, use
preparePresortedFileTreeInput(...). That skips both the tree-shaping work and
the extra ordering work a normal prepared input pass would still apply.
1234567import { preparePresortedFileTreeInput } from '@pierre/trees';
const preparedInput = preparePresortedFileTreeInput([ 'README.md', 'src/index.ts', 'src/components/Button.tsx',]);Use this when the backend, build step, or cached index already owns the sort order. Do not reimplement default ordering manually in the client just to reach the presorted path.
The recommended split is simple:
preparedInput into React, vanilla, or SSR hydration.That reduces client CPU work, makes startup cost more predictable, and lets the same prepared payload feed every runtime.
Sometimes data only exists in the browser, or custom ordering must run there. Trees supports that, but it is the exception path. Start with prepared input when the app can move work out of the UI, and treat client-side shaping as a fallback.
For the shared contract behind these inputs, see Shared concepts. For the scale-oriented version of the same guidance, continue with Handle large trees efficiently.
The tree model tracks three user-facing states that matter immediately: selection, focus, and the visible row set. Search layers on top of that same model instead of introducing a separate identity system.
Selection, focus, keyboard movement, and search all work in terms of canonical paths. If the visible tree changes because a branch collapses or search filters rows away, your app still reasons about the same path values.
That is why the public APIs return readonly string[] for selected paths, path
strings for focused items, and path strings for search matches.
Focus tells you where keyboard actions land. Selection tells you which rows the app currently treats as chosen. They often move together, but they are not interchangeable.
If you care about multi-select state in surrounding UI, read the selected paths. If you care about where rename, keyboard navigation, or a command will land next, read the focused path or item handle.
Keyboard movement works on the currently visible, expanded tree. Expanding or collapsing a branch changes which row can receive focus next. Search changes the same visible tree, so search also changes where keyboard movement can land.
That behavior is why Trees exposes focus and selection through the model instead of through DOM order or row indexes.
Search matches the same path-first tree data. It changes what stays visible, but it does not invent a second identity model. Selection and focus still refer to paths, even while the visible window changes around the current query.
hide-non-matchesStart with hide-non-matches unless you have a stronger product reason to
preserve unrelated branches on screen. It aligns with the common "find the thing
I want" workflow, and it keeps the visible tree close to the current search
intent.
Trees also supports collapse-non-matches and expand-matches. Use those modes
when the surrounding UI needs more context, but keep hide-non-matches as the
default mental model. The shared meaning of all three modes lives in
Shared concepts.
React reads from the model through selector hooks and writes back through model methods.
123456789101112131415161718192021222324252627282930import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelection,} from '@pierre/trees/react';
export function SearchPanel({ paths }: { paths: readonly string[] }) { const { model } = useFileTree({ paths, search: true, fileTreeSearchMode: 'hide-non-matches', }); const selectedPaths = useFileTreeSelection(model); const search = useFileTreeSearch(model);
return ( <div className="space-y-3"> <label className="block"> <span>Search</span> <input value={search.value} onChange={(event) => search.setValue(event.target.value)} /> </label> <p>{selectedPaths.length} selected</p> <FileTree model={model} className="rounded-lg border" /> </div> );}Use useFileTreeSelector(...) when sibling UI needs a custom derived snapshot.
Vanilla code wires the same behavior around the class instance.
1234567891011121314const fileTree = new FileTree({ paths, search: true, fileTreeSearchMode: 'hide-non-matches',});
fileTree.render({ fileTreeContainer: container });searchInput.addEventListener('input', () => { fileTree.setSearch(searchInput.value);});
const selectedPaths = fileTree.getSelectedPaths();const focusedPath = fileTree.getFocusedPath();const matchingPaths = fileTree.getSearchMatchingPaths();The DOM host is not the source of truth. The model is.
This guide covers the main row-level editing workflows: rename first, drag and drop second, and optional command surfaces such as context menus after that. Every callback stays path-first.
The core row workflows are:
Teach those flows before you reach for a generic mutation inventory. Users care about which path moved, which path was renamed, and which row stays focused next.
Enable renaming when users should be able to rename rows inline. The common
policy hooks are:
canRename(item) to block protected files or foldersonRename(event) to react to the final path changeonError(error) to surface invalid rename attemptsThe rename event reports the source path, the destination path, and whether the renamed item was a folder.
1234567891011121314const { model } = useFileTree({ paths, renaming: { canRename: (item) => item.path !== 'package.json', onRename: ({ sourcePath, destinationPath }) => { console.log(`Renamed ${sourcePath} -> ${destinationPath}`); }, onError: (message) => { console.error(message); }, },});
model.startRenaming('src/index.ts');Enable dragAndDrop when users should move files or folders directly in the
tree. The practical hooks are:
canDrag(paths) to lock specific pathscanDrop(event) to reject invalid destinationsonDropComplete(event) for persistence or adjacent UI updatesonDropError(error, event) for visible failures123456789101112131415161718const fileTree = new FileTree({ paths, dragAndDrop: { canDrag: (draggedPaths) => draggedPaths.includes('package.json') === false, canDrop: ({ target }) => target.directoryPath !== 'dist/', onDropComplete: ({ draggedPaths, target }) => { console.log( 'Moved', draggedPaths, 'to', target.directoryPath ?? '(root)' ); }, onDropError: (message) => { console.error(message); }, },});Drop callbacks report dragged paths plus the resolved target shape. They do not ask you to reverse-engineer DOM rows.
A common editable project tree enables both renaming and dragAndDrop, then
adds policy guards for protected paths.
Typical rules include:
package.jsondist/Context menus are secondary command surfaces over the same model. Keep rename and drag available without requiring the menu.
In React, the wrapper can render menu content for you:
12345678910111213141516171819202122232425262728const { model } = useFileTree({ paths, composition: { contextMenu: { enabled: true, triggerMode: 'both', buttonVisibility: 'when-needed', }, }, renaming: true,});
<FileTree model={model} renderContextMenu={(item, context) => ( <div className="rounded-md border bg-background p-2 shadow"> <button onClick={() => { context.close({ restoreFocus: false }); model.startRenaming(item.path); }} type="button" > Rename </button> </div> )}/>;In vanilla, use composition.contextMenu.render when you want to provide the
menu element directly from the runtime config.
The focused row is the anchor for rename and keyboard-invoked commands. Opening and closing a menu should restore focus predictably, and keyboard users should still be able to reach the main actions without relying on pointer-only gestures.
Trees emits path-based rename and drop events. Your app decides whether to persist those changes to a server, local state, or another boundary. Keep that separation clear.
If you need the shared vocabulary behind those events, read Shared concepts. For runtime lookup, jump to React API or Vanilla API.
Start with the host element, then move inward. Host styles control the outer
panel. CSS variables control most tree-internal appearance.
themeToTreeStyles(...) maps an editor-like theme into the same variable
system. unsafeCSS is the escape hatch when the supported surfaces do not reach
a narrow case.
The host element is the outer panel boundary. Use it for width, height, borders, radius, background, and layout placement.
In React, pass normal host props to <FileTree model={...} />.
12345678<FileTree model={model} className="h-96 rounded-xl border" style={{ backgroundColor: 'var(--panel)', borderColor: 'var(--border)', }}/>In vanilla, style the mounted host element you already own. After mount,
getFileTreeContainer() gives you that element back when runtime code needs to
update it.
CSS variables are the main public styling surface inside the shadow root. Reach for them before custom CSS injection.
123456789101112<FileTree model={model} style={ { '--trees-theme-list-active-selection-bg': 'color-mix(in oklab, var(--accent) 24%, transparent)', '--trees-theme-list-hover-bg': 'color-mix(in oklab, var(--accent) 12%, transparent)', '--trees-theme-focus-ring': 'var(--accent)', } as React.CSSProperties }/>That approach keeps the normal fallback chain intact:
--trees-theme-* variables from theme helpersFor the token families behind those variables, see Styling and theming.
themeToTreeStyles(...)Use themeToTreeStyles(...) when your app already has a VS Code or Shiki-style
theme object. It maps that theme to host styles plus the --trees-theme-*
variables the tree already understands.
1234567891011121314import { themeToTreeStyles } from '@pierre/trees';
const treeStyles = themeToTreeStyles(theme);
<FileTree model={model} style={ { ...treeStyles, '--trees-theme-list-active-selection-bg': 'color-mix(in oklab, var(--accent) 28%, transparent)', } as React.CSSProperties }/>;That gives you matching panel colors, selection colors, search-field colors, and Git-status colors without rebuilding the theme system by hand.
densityPass density to useFileTree (or preloadFileTree, or the vanilla FileTree
constructor) to bundle row height and spacing into one option. The keyword form
('compact', 'default', 'relaxed') resolves both at once; a numeric form
keeps the default row height and supplies a custom spacing factor. Every runtime
— vanilla CSR, vanilla SSR, React CSR, and React SSR — paints
--trees-item-height and --trees-density-override onto the host from the
resolved density so the virtualized and painted row heights stay aligned.
Caller-set inline values on the host still win, so you can override either
variable directly when you need a one-off. Set itemHeight only when you need a
row height that doesn't match a preset.
The keyword presets are exported as FILE_TREE_DENSITY_PRESETS so SSR helpers
like initialVisibleRowCount can divide by the preset's row height without
hard-coding it.
Use:
themeToTreeStyles(...) when the tree should inherit an editor palettegetFileTreeContainer() in vanilla when runtime code needs the mounted host
elementLayer explicit overrides on top when the imported theme is close but not final.
unsafeCSS is the escape hatchunsafeCSS is for the cases the supported host and variable surfaces cannot
express. Keep it small, local, and secondary.
12345678const fileTree = new FileTree({ paths, unsafeCSS: ` [data-item-button][data-item-focused="true"] { text-decoration: underline; } `,});Do not start here, and do not rebuild the whole visual system from raw selectors. If you need a broad lookup of the supported styling surfaces, read Styling and theming. For icon-specific appearance changes, continue with Customize icons.
Start with the built-in icon sets, then add targeted remaps only where your product needs them. Most apps do not need to replace the whole icon system.
Trees ships three built-in sets:
minimal for low-noise file and folder visualsstandard for common language and file-type recognitioncomplete for the broadest built-in coverageIf you only need a different baseline, pass the set name directly.
1234const fileTree = new FileTree({ paths, icons: 'standard',});Built-in sets default to semantic icon colors. Turn that off before you reach for a sprite sheet if the product wants a quieter or monochrome look.
1234567const fileTree = new FileTree({ paths, icons: { set: 'complete', colored: false, },});For broader appearance control, use the styling system instead of treating icons as a parallel theme surface. See Style and theme the tree.
Switch to FileTreeIconConfig when a plain set name is not enough. The
practical remap surfaces are:
remap for built-in slots such as the generic file icon, chevron, dot, or
lockbyFileName for exact basenames such as package.jsonbyFileExtension for suffixes such as ts or spec.tsbyFileNameContains for broader patterns such as dockerfile123456789101112131415161718const fileTree = new FileTree({ paths, icons: { set: 'standard', byFileName: { 'package.json': 'icon-package-json', }, byFileExtension: { 'spec.ts': 'icon-test-file', }, byFileNameContains: { dockerfile: 'icon-dockerfile', }, remap: { 'file-tree-icon-lock': 'icon-locked', }, },});That keeps the built-in mapping for everything you did not customize.
File-specific rules beat broader ones. In practice, Trees resolves icons in this order:
If you want the full lookup contract, read Icons.
spriteSheet is the path when you already have branded SVG symbols or need a
few custom symbols that should coexist with the built-in set.
12345678910111213141516const fileTree = new FileTree({ paths, icons: { set: 'standard', spriteSheet: ` <svg aria-hidden="true" width="0" height="0"> <symbol id="icon-package-json" viewBox="0 0 16 16"> <circle cx="8" cy="8" r="7" fill="currentColor" /> </symbol> </svg> `, byFileName: { 'package.json': 'icon-package-json', }, },});Keep the contract simple: provide <symbol> definitions, then reference those
symbols through remap rules. Do not turn sprite sheets into the default docs
path.
Use built-in gitStatus when the signal is Git-like. Reach for
renderRowDecoration when the row needs product-specific metadata that is not
Git state.
gitStatusgitStatus is the default row-signal path. It attaches statuses to canonical
paths, and folders can reflect changed descendants automatically.
Trees supports these built-in statuses:
addedmodifieddeletedignoredrenameduntracked123456789const fileTree = new FileTree({ paths, gitStatus: [ { path: 'README.md', status: 'untracked' }, { path: 'package.json', status: 'renamed' }, { path: 'src/index.ts', status: 'modified' }, { path: 'src/components/Button.tsx', status: 'added' }, ],});That is the shortest path when the row signal already matches Git semantics.
gitStatus is enoughUse gitStatus when the meaning is actually Git-like. Do not invent fake Git
state just to get a badge on screen. Let Trees own the built-in status lane, and
let the styling system control how those signals look.
When the surrounding app changes commits, branches, comparison views, or status visibility, replace the current status data explicitly.
12fileTree.setGitStatus(nextStatuses);fileTree.setGitStatus(undefined);That keeps the runtime truthful without promising a filesystem watcher or a repo-sync framework.
renderRowDecorationUse renderRowDecoration when the row needs metadata that is not Git-like.
1234567891011121314const fileTree = new FileTree({ paths, renderRowDecoration: ({ item }) => { if (item.path.endsWith('.generated.ts')) { return { text: 'GEN', title: 'Generated file' }; }
if (item.path.startsWith('remote/')) { return { icon: 'icon-remote', title: 'Remote source' }; }
return null; },});Good fits include generated-file markers, remote-storage indicators, validation markers, or short secondary labels.
Use:
gitStatus when the meaning is Git-likerenderRowDecoration for everything elseKeep annotations short and glanceable. If a decoration affects user decisions, give it accessible text or a tooltip.
Git-status colors and decoration visuals should stay in the same appearance system as the rest of the tree. Use styling and theming controls for color, not a second annotation-specific theme layer.
For that part of the API, see Style and theme the tree and Styling and theming.
Large-tree performance starts by reducing unnecessary client work. Shape and order the data before it reaches the UI, then tune rendering only where the visible window actually needs help.
Use:
paths for small demos and low-ceremony casespreparedInput for larger treespreparePresortedFileTreeInput(...) when the server already owns the final
orderThat order matters. Do not jump straight to rendering knobs if the client is still doing avoidable shaping or sorting work.
For larger trees, move shaping work out of the UI. Prepared input is the scale-oriented public path.
123456import { preparePresortedFileTreeInput } from '@pierre/trees';
export async function loadWorkspaceTree() { const sortedPaths = await fetchSortedWorkspacePaths(); return preparePresortedFileTreeInput(sortedPaths);}If you only have an unsorted path list, use prepareFileTreeInput(...) instead.
Either way, pass the prepared result into React, vanilla, or SSR hydration.
Trees already virtualizes the visible row window. Most apps do not need custom virtualization primitives.
The main rendering knobs are:
initialVisibleRowCount, when you want to budget SSR or first-render work
before measurementdensity, when your design changes row density enough to matter — pass
'compact' | 'default' | 'relaxed' (or a custom numeric factor) to resolve
both the row height and the surrounding spacing in one place. Use itemHeight
only when you need a row height that doesn't match a preset.overscan, when you need to trade a little extra work for smoother scrolling123456const { model } = useFileTree({ preparedInput, initialVisibleRowCount: 14, itemHeight: 30, overscan: 8,});Give the rendered tree host a real CSS height. The row-count hint only shapes the first render before the browser can measure the actual viewport.
Keep the language outcome-focused: the tree mounts the visible slice plus a small buffer around it.
Prepared input lowers the cost of shaping the data, but expansion and search still change how many rows stay visible at once. Large expanded trees and broad search results can widen the mounted window.
That is another reason to start with the input story first. If the visible tree changes often, avoid recomputing the underlying shape in the client at the same time.
Large trees often benefit from server preload. The same scale rule still applies: prepare or presort the input on the server once, preload the tree once, and let the client hydrate that work instead of recomputing it.
Read SSR when first paint matters, and SSR API when you need the handoff contract.
Avoid these patterns when the tree grows:
paths for huge server-known datasetsresetPaths(...) with matching prepared input would
do the jobUse SSR when you want the tree to arrive from the server with a fast first paint, then become interactive on the client. This is not a third primary runtime. It is a preload-and-hydrate layer over the same React or vanilla model.
Import preload helpers from @pierre/trees/ssr, call preloadFileTree(...) on
the server, and treat the result as one opaque handoff object.
123456789import { preloadFileTree } from '@pierre/trees/ssr';
const payload = preloadFileTree({ preparedInput, id: 'project-tree', initialExpandedPaths: ['src'], search: true, initialVisibleRowCount: 11,});Steady-state height still comes from the rendered container.
initialVisibleRowCount is only a first-render hint for SSR and hydration
before the browser can measure the real viewport.
Do not unpack the payload into a field-by-field integration story in application code. Pass it forward unchanged.
Server and client must agree on the same tree-defining options. Match the same
input source, the same id discipline, and the same state-affecting options
that change the first rendered tree.
If the server preloads one tree and the client constructs a different one, hydration mismatch is the expected outcome.
React still creates the model with useFileTree(...). The difference is that
the wrapper receives the opaque handoff object as preloadedData.
123456789101112131415161718192021import { FileTree, useFileTree } from '@pierre/trees/react';import type { FileTreePreparedInput } from '@pierre/trees';import type { FileTreeSsrPayload } from '@pierre/trees/ssr';
export function ProjectTreeClient({ preparedInput, preloadedData,}: { preparedInput: FileTreePreparedInput; preloadedData: FileTreeSsrPayload;}) { const { model } = useFileTree({ preparedInput, id: preloadedData.id, initialExpandedPaths: ['src'], search: true, initialVisibleRowCount: 11, });
return <FileTree model={model} preloadedData={preloadedData} />;}The model stays primary. preloadedData only activates hydration.
Vanilla uses the same preload step, but the client hydrates the server-rendered container that is already in the page.
1234567891011121314import { FileTree } from '@pierre/trees';
const fileTree = new FileTree({ preparedInput, id: 'project-tree', initialExpandedPaths: ['src'], search: true, initialVisibleRowCount: 11,});
const container = document.getElementById('project-tree');if (container instanceof HTMLElement) { fileTree.hydrate({ fileTreeContainer: container });}If that server-rendered container is missing, render normally instead of hydrating.
In docs and application code, refer to the preload result as an SSR payload or
handoff object. React consumes it as preloadedData. Vanilla hydrates existing
server markup. Both flows reuse the same server work without teaching payload
internals as the main story.
Large-tree SSR works best when the server already owns the expensive shaping work. Prepare or presort the input on the server, preload once, then let the client hydrate that same result.
The preloaded path uses declarative shadow DOM under the hood. In React, the packaged wrapper already handles the host ownership details it needs for hydration. Use the runtime behavior instead of inventing custom DOM diffing or raw payload plumbing.
For the API-level handoff contract, read SSR API.
This page owns the cross-runtime language for Trees. React, vanilla, and SSR all use the same path-first identity model, the same tree-defining inputs, and the same mutation vocabulary.
Canonical path strings are the public identity for tree items. Use paths everywhere:
Files and directories share the same identity space. APIs distinguish the item kind when that difference matters.
Trees accepts three related input shapes:
paths: the low-ceremony path for demos, tests, and small static treespreparedInput: the recommended scale-oriented input shapeUse prepareFileTreeInput(...) and preparePresortedFileTreeInput(...) from
@pierre/trees to create the prepared forms. Treat prepared input as an opaque
tree-input object, not as a hand-rolled shape.
This table covers the shared meaning of the public options surface across runtimes.
| Option | Meaning | Typical use |
|---|---|---|
paths | Raw canonical path list. | Small demos, tests, very small static trees. |
preparedInput | Pre-shaped tree input created ahead of render. | Recommended input for larger trees. |
id | Stable host identity for the tree. | Coordinate server preload and client hydration, or set a predictable DOM id. |
initialExpansion | Baseline expansion policy for the first render. | Start broadly open, broadly closed, or at a specific depth. |
initialExpandedPaths | Specific paths that should begin expanded. | Keep key folders open on first render. |
initialSelectedPaths | Paths selected on first render. | Seed selection for previews or restored state. |
flattenEmptyDirectories | Flattens chains of single-child directories into one visible row. | Compact repo-style trees. |
sort | Client-side ordering for non-presorted input. | Secondary path when order must be chosen in the client. |
search | Enables the built-in search surface and model search methods. | Searchable trees. |
initialSearchQuery | Starting search value. | Preloaded filtered views or restored search state. |
fileTreeSearchMode | Controls how matches change the visible tree. | Choose between filtering, collapsing, or expanding around matches. |
onSearchChange | Callback for search-value changes. | Sync surrounding controls or analytics. |
onSelectionChange | Callback for selection changes. | Update sibling UI that tracks current selection. |
dragAndDrop | Enables drag and drop plus its policy hooks. | Editable trees. |
renaming | Enables inline rename plus policy hooks. | Rename-in-place workflows. |
composition | Header and context-menu composition surface. | Add a header row or contextual commands. |
gitStatus | Built-in Git-style row signals. | Added, modified, deleted, ignored, renamed, and untracked states. |
icons | Built-in icon set or icon configuration object. | Set selection, color mode, remaps, or sprite-sheet extension. |
renderRowDecoration | Custom non-Git row signal renderer. | Generated-file badges, remote markers, validation hints. |
density | Density preset or custom spacing factor. | Tune row height and spacing in one place. |
itemHeight | Explicit row-height override. | Row height that doesn't match a density preset. |
overscan | Extra rows rendered outside the visible window. | Smooth scrolling tradeoffs. |
initialVisibleRowCount | Optional first-render row budget before the browser measures height. | Tune SSR and hydration work without pinning steady-state size. |
unsafeCSS | Advanced CSS injection into the tree shadow root. | Narrow escape hatch when supported styling surfaces are not enough. |
The tree-shape options are paths, preparedInput, initialExpansion,
initialExpandedPaths, flattenEmptyDirectories, and sort.
Use them to answer three questions:
For the recommended setup, read Shape tree data for fast rendering.
Trees supports three search modes:
hide-non-matches: filters visible rows down to matches plus the ancestor
chain needed to keep them navigable. This is the guide default.collapse-non-matches: keeps matching paths and their ancestor chain visible,
but collapses unrelated branches out of the way.expand-matches: expands matching branches into surrounding tree context and
keeps non-matching rows visible.React and vanilla use the same search modes. Runtime pages should not redefine them.
Selection is path-based. Focus is path-based. Item handles are path-oriented helpers for a known item.
Common lookup topics are:
Use Navigate selection, focus, and search for workflows, React API for selector hooks, and Vanilla API for direct class methods.
dragAndDrop, renaming, and composition are the runtime-agnostic editing
surfaces. They share the same path-first event model even though the React and
vanilla integration points differ.
Use Rename, drag, and trigger item actions for the user-facing workflows built on top of those options.
gitStatus, icons, renderRowDecoration, and unsafeCSS all affect how rows
look, but they do different jobs:
gitStatus is the built-in Git-style signal laneicons controls icon sets, remaps, and sprite-sheet extensionrenderRowDecoration adds non-Git row metadataunsafeCSS is the narrow fallback when public styling surfaces are not enoughFor deeper lookup, jump to Styling and theming and Icons.
initialVisibleRowCount, density, itemHeight, and overscan are rendering
knobs, not onboarding concepts. The rendered container sets steady-state height;
these options only tune the first-render budget, visual density, and the
virtualized row window.
For density, prefer the density keyword ('compact' | 'default' | 'relaxed')
or a numeric factor — it resolves both row height and spacing in one place, and
the React <FileTree> wrapper paints --trees-item-height and
--trees-density-override for you. Reach for itemHeight only when you need a
row height that doesn't match a preset.
Use Handle large trees efficiently when scale becomes the real problem.
Trees exposes semantic tree mutations rather than DOM events. The shared mutation terms are:
addremovemovebatchresetPathsonMutationMutation payloads stay path-first. Reset events also tell you whether prepared input was involved, and invalidation metadata tells you whether canonical state or projected visible state changed.
Use these events to sync adjacent UI, analytics, or persistence layers. Do not turn them into a filesystem-sync tutorial.
Server preload returns one handoff object. Pass it forward unchanged.
React consumes that handoff as preloadedData. Vanilla hydrates existing server
markup through hydrate({ fileTreeContainer }). Neither runtime should teach
field-by-field payload choreography as the main story.
For the preload and hydration contract, read SSR API.
The React entry point lives in @pierre/trees/react. It is a thin React
integration layer over the same imperative model the vanilla runtime uses.
Start with useFileTree(options), not with a giant controlled component. The
hook creates one stable model for the component lifetime. Later option changes
are not a controlled reconfiguration path. Update the model through model
methods instead.
123456import { FileTree, useFileTree } from '@pierre/trees/react';
export function ProjectTree({ paths }: { paths: readonly string[] }) { const { model } = useFileTree({ paths, search: true }); return <FileTree model={model} />;}For the shared option meanings behind that call, read Shared concepts.
useFileTree(...)useFileTree(...):
UseFileTreeResult, which currently exposes modelKeep using model methods such as resetPaths(...), setComposition(...),
setGitStatus(...), setIcons(...), and the search or mutation methods when
the tree changes after mount.
<FileTree />The React component mounts the model into a host element.
Primary props:
modelclassName, style, and idReact-only props:
headerrenderContextMenupreloadedDataBehavior notes:
id ?? preloadedData?.id for host identity when hydration is involvedheader and renderContextMenu layer React rendering onto the model's
composition surfaceReact components read from the model through selector hooks and write back through model methods.
Common read patterns include:
useFileTreeSelector(model, selector, equality?)
useFileTreeSelection(model)
readonly string[] selected pathsuseFileTreeSearch(model)
isOpen, matchingPaths, valueopen(initialValue?), close(), setValue(value),
focusNextMatch(), focusPreviousMatch()123456const { model } = useFileTree({ paths, search: true });const selectedPaths = useFileTreeSelection(model);const search = useFileTreeSearch(model);const focusedPath = useFileTreeSelector(model, (currentModel) => currentModel.getFocusedPath());React writes through the same imperative model methods the vanilla runtime exposes.
Typical write paths are:
setSearch(...) and openSearch(...)add(...), remove(...), move(...), batch(...),
and resetPaths(...)setComposition(...),
setGitStatus(...), and setIcons(...)React adds two high-level composition props on top of the model:
header for React-rendered header contentrenderContextMenu(item, context) for React-rendered context menusUse them when the menu or header belongs in React. The shared meaning of the underlying composition options still lives in Shared concepts.
Use selector hooks when React UI needs reactive reads. Use
model.onMutation(...) when you need semantic side effects such as persistence,
logging, or analytics around add, remove, move, reset, or batch operations.
The React runtime consumes server preload through preloadedData on
<FileTree />. The model still comes from useFileTree(...), and the client
must use matching tree-defining options.
For the preload contract, read SSR API. For workflow guidance, read SSR.
Style the React host with normal host props such as className and style. For
deeper lookup, use Styling and theming and
Icons.
The vanilla runtime lives at the package root, @pierre/trees.
new FileTree(...) is both the runtime entry point and the imperative model
surface.
Start with new FileTree(options), then mount the instance.
12345678import { FileTree } from '@pierre/trees';
const fileTree = new FileTree({ paths: ['README.md', 'src/index.ts'], search: true,});
fileTree.render({ fileTreeContainer: container });For shared option meanings, read Shared concepts.
new FileTree(options):
render(...)
{ fileTreeContainer } when you already own the host element{ containerWrapper } when the runtime should create and append the
host for youhydrate({ fileTreeContainer })
unmount()
cleanUp()
getFileTreeContainer()
Common read methods are:
getItem(path)getFocusedItem()getFocusedPath()getSelectedPaths()getComposition()isSearchOpen()getSearchValue()getSearchMatchingPaths()These reads stay path- and model-oriented. They do not ask you to infer tree state from DOM order.
getItem(path) returns either a file handle, a directory handle, or null.
Shared handle actions:
getPath()focus()select()toggleSelect()deselect()Directory-only actions:
expand()collapse()toggle()Focus and selection control:
focusPath(path)focusNearestPath(path | null)startRenaming(path?)Search control:
setSearch(value)openSearch(initialValue?)closeSearch()focusNextSearchMatch()focusPreviousSearchMatch()Data and mutation control:
add(path)remove(path, options?)move(fromPath, toPath, options?)batch(operations)resetPaths(paths, options?)onMutation(type, handler)Runtime reconfiguration helpers:
setComposition(composition?)setGitStatus(gitStatus?)setIcons(icons?)Use subscribe(listener) when any tree change should refresh a derived
snapshot. Use onMutation(...) when add, remove, move, reset, or batch intent
matters.
That separation keeps model subscriptions for state reads and mutation events for semantic side effects.
The composition surface covers:
setComposition(...)Use Rename, drag, and trigger item actions for workflow-level guidance.
This page only names the vanilla hydration entry point:
hydrate({ fileTreeContainer }). The full server-preload-first contract lives
in SSR API.
This page names the runtime touchpoints such as getFileTreeContainer(),
setGitStatus(...), and setIcons(...). For styling variables, theme helpers,
and unsafeCSS, read Styling and theming. For icon
remaps and icon-set lookup, read Icons.
The SSR entry point lives in @pierre/trees/ssr. It owns server preload and the
server-to-client handoff contract.
Start with the server step, not the client step. Call preloadFileTree(options)
on the server, then pass the returned value forward as one opaque handoff
object.
12345678import { preloadFileTree } from '@pierre/trees/ssr';
const payload = preloadFileTree({ preparedInput, id: 'project-tree', initialExpandedPaths: ['src'], initialVisibleRowCount: 11,});Hydration reuses that server work. It is not a separate rendering strategy with a different public data model.
preloadFileTree(...)preloadFileTree(...):
FileTreeOptions surfaceThe current exported payload type name is FileTreeSsrPayload, but the
docs-facing rule stays the same: pass it through unchanged.
serializeFileTreeSsrPayload(...)serializeFileTreeSsrPayload(payload, mode?) turns the preloaded payload back
into a full host-markup string.
Use it when your server integration needs a serialized HTML string instead of passing the payload through a framework-specific render boundary. The payload still stays opaque at the docs level.
Treat the preload result as a handoff object, not as a field-by-field integration surface.
Docs can name the runtime touchpoints that consume it:
preloadedDatahydrate({ fileTreeContainer })Do not center raw payload fields as the public story.
Server and client must agree on the same tree-defining options.
Match:
paths, prepared input, or presorted prepared input)id disciplineMismatches are not a supported partial merge. They produce hydration mismatch or incorrect first state.
Hydration works with raw paths or prepared input, but the recommended scale
path is server-prepared input with matching client consumption.
For the shared input model behind that rule, read Shared concepts.
React still creates the model with useFileTree(...), then passes the payload
to <FileTree model={model} preloadedData={payload} />.
For the full runtime surface, read React API. For the workflow guide, read SSR.
Vanilla emits the server-rendered tree into the page first, creates
new FileTree(options) on the client, then calls
fileTree.hydrate({ fileTreeContainer }) against the existing host.
For the full runtime surface, read Vanilla API. For the workflow guide, read SSR.
If you mention declarative shadow DOM or hydration warnings, keep that note short and secondary. This page owns the public handoff contract, not DOM internals.
This page is the lookup reference for host styling, CSS-variable families,
themeToTreeStyles(...), and the unsafeCSS escape hatch.
Host styling comes first.
className and style props on
<FileTree model={...} />getFileTreeContainer()Host styles own width, height, borders, background, and panel placement. CSS variables control the UI rendered inside that host.
The tree styling surface is organized around variable families rather than one flat list.
Core families include:
Trees resolves styling in this order:
--trees-*-override--trees-theme-* variables supplied by theme helpersThat means themeToTreeStyles(...) fills the middle layer, and direct overrides
still win.
themeToTreeStyles(...)themeToTreeStyles(theme) maps a VS Code or Shiki-style theme object into host
styles plus the --trees-theme-* variables the tree already understands.
Input shape:
TreeThemeInputtype, bg, fg, colorsOutput shape:
TreeThemeStyles123import { themeToTreeStyles } from '@pierre/trees';
const treeStyles = themeToTreeStyles(theme);The helper covers panel colors, selection colors, focus rings, input colors, and Git-status colors. Explicit overrides can still replace any derived token later.
React:
className and style propsVanilla:
getFileTreeContainer() is the handoff back to application styling codeunsafeCSS escape hatchunsafeCSS is the secondary path. Use it only when the supported host and
variable surfaces cannot express the needed customization.
Reference topics:
Keep it narrow. Do not turn it into a raw stylesheet-asset strategy.
This page is the lookup reference for icon sets, icon remaps, sprite-sheet extension, and icon-specific configuration rules.
Built-in set selection:
icons: 'minimal' | 'standard' | 'complete'minimal keeps the visual noise lowstandard adds common language and file-type iconscomplete has the broadest built-in coverageObject configuration:
FileTreeIconConfig when a string set is not enoughBase set and color mode:
set?: 'minimal' | 'standard' | 'complete' | 'none'colored?: booleanset: 'none' disables built-in file-type mappings. File rows still fall back to
the generic file icon unless remaps replace it.
Sprite-sheet extension:
spriteSheet?: string<symbol> definitions that remaps can targetBuilt-in slot remapping:
remap?: Record<string, RemappedIcon>File-specific remapping:
byFileName for exact basename matches such as package.jsonbyFileNameContains for basename substring rules such as dockerfilebyFileExtension for extension rules without a leading dot, including
multi-part suffixes such as spec.tsFile icon lookup resolves in this order:
byFileNamebyFileNameContainsbyFileExtension, with more specific suffixes winningThat means spec.ts should beat ts when both rules exist.
RemappedIcon shapeRemappedIcon supports two forms:
name, plus optional width, height, and viewBoxUse the object form when the replacement symbol needs non-default geometry metadata.
React:
useFileTree(...)Vanilla:
setIcons(...)For the runtime-specific lookup around those touchpoints, read React API and Vanilla API.
Built-in colored icons can be disabled with colored: false. The actual token
lookup and fallback precedence for icon colors live in
Styling and theming.