Trees is in beta. Start from the public React, vanilla, and SSR entry points on this page. Expect refinements and small API changes between beta releases.
Trees uses one path-first model. The model works the same across React, vanilla, and SSR hydration. Selection, focus, search, rename, drag and drop, Git status, and row annotations all use canonical paths.
These docs are guide-first. First, select your runtime. Next, shape the tree data before it reaches the UI. Then add search, item actions, styling, icons, row signals, or SSR when you need them.
@pierre/trees gives you one path-first model and two primary runtime entries:
a thin React layer in @pierre/trees/react and the
vanilla class in @pierre/trees.
Use canonical path strings as the public identity for each item in the tree. For
example, src/components/Button.tsx is more than a label on screen. It is the
value that you read from selection state. It is the path that you focus in code.
It is also the target that you rename or move later.
To learn the shared terms behind this rule, read Shared concepts.
Use the React entry point when your UI is already in React. For more information, read the getting started with React guide.
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" />;}Use the vanilla class in two cases. Use it when your app is not React-based. Also use it when another framework must own the lifecycle around an imperative model. For more information, read getting started with vanilla JS.
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 use the same tree data. Small examples can start with raw paths.
But real application trees must move to prepared input. This step prevents the
client from doing the shaping work on every load. Read
Shape tree data for fast rendering after
your runtime quickstart. This step applies to both React and vanilla JS.
Use the React entry point when your UI is already in React. The hook creates one stable tree model. The component mounts that model into the host element.
@pierre/treesUse the package root for the vanilla runtime. Use the /react entry point for
the React wrapper.
1234pnpm add @pierre/trees# npm: npm install @pierre/trees# bun: bun add @pierre/trees# yarn: yarn add @pierre/treesuseFileTree(...)useFileTree(...) from @pierre/trees/react creates the model one time for the
component lifetime. Later option changes do not update the model. Update the
model through methods when the tree data or runtime behavior changes after
mount. The methods include resetPaths(...), setComposition(...),
setGitStatus(...), and setIcons(...).
For small trees, pass raw paths. For scalable trees, use preparedInput.
Create preparedInput 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' }} /> );}Read Shape tree data for fast rendering after this quickstart if you must still decide how to shape that input.
<FileTree model={model} /><FileTree /> is a thin React wrapper over the model. It mounts the tree into
the host element. It forwards normal host props such as className and style.
It also hydrates existing server output when you pass preloadedData later.
Keep the mental model simple. The model owns the tree state. The React component renders it.
React code reads snapshots from the model through selector hooks. React code 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. This hook does not rerender on unrelated tree changes.
For the shared interaction terms, 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. Move the shape or sort work out of the UI
when the tree becomes expensive on the client.
Use this recommended scale path:
preparedInput into useFileTree(...).Use preparePresortedFileTreeInput(...) when the server already knows the final
order. It is the highest-performance prepared-input variant. The client can skip
both the shaping work and the extra sorting work.
Hydration builds on top of the same model-first React story. The client still
calls useFileTree(...). The React wrapper still renders the same model. The
one difference is that the tree starts from preloaded server output.
Continue with SSR and SSR API when you need that flow.
Use the vanilla runtime in two cases. Use it when your app is not React-based.
Also use it when another framework must own the lifecycle around an imperative
tree model. new FileTree(...) creates the model. render(...) or
hydrate(...) attaches the model to the DOM.
@pierre/trees1234pnpm add @pierre/trees# npm: npm install @pierre/trees# bun: bun add @pierre/trees# yarn: yarn add @pierre/treesnew FileTree(...)The class instance is the runtime entry point and the state surface. For small
trees, pass raw paths. For scalable trees, use preparedInput. Create
preparedInput 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.
Use render({ containerWrapper }) instead when the runtime must create the host
for you.
Keep the boundary clear. The model owns the tree state. The mounted host only renders that state. Do not read the DOM to find the selected item or the 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();Call explicit model methods when the surrounding data changes. The methods
include resetPaths(...), setComposition(...), setGitStatus(...), and
setIcons(...). Do not build the model again 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.
Use this recommended scale path:
new FileTree({ preparedInput, ... }).Use preparePresortedFileTreeInput(...) when the server or indexer already
knows the final order. It is the better fit because the client can skip the
extra sorting work.
Hydration builds on top of the same class-first runtime. The client still
creates new FileTree(...). But the client does not render fresh markup.
Instead, it attaches that model to server-rendered tree output with
hydrate({ fileTreeContainer }).
Continue with SSR and SSR API when you need that flow.
Keep the same ownership boundary when you wrap the vanilla model in another framework:
FileTree instance from that framework's lifecycleThis approach keeps React as the only first-class wrapper surface. It does not change how the model works.
Shape the tree before it reaches the UI when the dataset is large. Then this out-of-UI preparation is worth the effort. Trees treats prepared input as a first-class public concept. It is not an internal optimization trick.
prepareFileTreeInput(...) lets the client skip repeated tree-shape work. Do
this preparation on the server, a loader, or another non-UI boundary. That
boundary 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 uses the same path-first model. Only the expensive preparation step moves earlier.
preparedInput into the runtimeBoth primary runtimes use 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 choice for small demos, tests, and very small
static trees.
123const fileTree = new FileTree({ paths: ['README.md', 'src/index.ts', 'src/components/Button.tsx'],});This is the easy start path, not the scale-oriented default. Move the preparation work out of the client when the tree grows.
Use preparePresortedFileTreeInput(...) when your server or indexer already
knows the final order. It skips the tree-shape work. It also skips the extra
sort work that a normal prepared-input pass applies.
1234567import { preparePresortedFileTreeInput } from '@pierre/trees';
const preparedInput = preparePresortedFileTreeInput([ 'README.md', 'src/index.ts', 'src/components/Button.tsx',]);Use this path when the backend, build step, or cached index already owns the sort order. Do not write the default order again in the client only to reach the presorted path.
Use this simple split:
preparedInput into React, vanilla, or SSR hydration.This split reduces client CPU work. It makes the startup cost more predictable. It also lets the same prepared payload feed every runtime.
Sometimes the data exists only in the browser. Sometimes custom order must run there. Trees supports both cases, but they are the exception path. Start with prepared input when the app can move the work out of the UI. Use client-side shaping only as a fallback.
To learn the shared contract behind these inputs, read Shared concepts. For the scale-oriented version of this guidance, continue with Handle large trees efficiently.
The tree model tracks three user-facing states: selection, focus, and the visible row set. Search builds on the same model. It does not add a separate identity system.
Selection, focus, keyboard movement, and search all use canonical paths. The visible tree can change when a branch collapses or when search removes rows. Your app still uses the same path values.
For this reason, the public APIs return path-based results. Selected paths are a
readonly string[]. Focused items are path strings. Search matches are path
strings.
Focus tells you where keyboard actions land. Selection tells you which rows the app treats as chosen. The two often move together. But they are not the same.
Read the selected paths when you need the multi-select state in surrounding UI. Read the focused path or item handle when you need to know where a rename, keyboard action, or command lands next.
Keyboard movement works on the visible, expanded tree. Expand or collapse a branch to change which row receives focus next. Search changes the same visible tree. So search also changes where keyboard movement lands.
For this reason, Trees exposes focus and selection through the model. It does not use DOM order or row indexes.
Search matches the same path-first tree data. It changes what stays visible. But it does not add a second identity model. Selection and focus still use paths. The visible window changes around the current query.
hide-non-matchesStart with hide-non-matches. Change it only when the product needs unrelated
branches on screen. This mode fits the common "find the thing I want" workflow.
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 is in
Shared concepts.
React reads from the model through selector hooks. React 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 connects the same behavior to 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 the source of truth.
This guide covers the main row-level editing workflows. Rename comes first. Drag and drop comes second. Optional command surfaces, such as context menus, come last. Every callback stays path-first.
The core row workflows are:
Learn these flows before you use a generic mutation inventory. Users care about three things. They care about which path moved. They care about which path changed its name. They care about which row stays focused next.
Enable renaming when users must rename rows inline. The common policy hooks
are:
canRename(item) to block protected files or foldersonRename(event) to respond to the final path changeonError(error) to show invalid rename attemptsThe rename event reports three values. It reports the source path. It reports the destination path. It reports whether the 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 must 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 the dragged paths and the resolved target shape. They do not ask you to find the DOM rows yourself.
A common editable project tree enables both renaming and dragAndDrop. Then
it 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. Do not require the menu.
In React, the wrapper can render the 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 to supply the menu element
directly from the runtime config.
The tree blocks scrolling inside its own pane while a context menu is open. This keeps the menu on its row. But the tree cannot reach the scroll containers that your page owns. A portaled menu attaches to a point in the viewport. So the menu disconnects from its row when the window scrolls. Lock the window scroll for the menu's lifetime. Release the lock when the menu closes:
12345678910111213141516171819202122// Hide the window scrollbar and compensate for its width so the page// content does not shift under the already-positioned menu.function lockWindowScroll(): () => void { const { body, documentElement } = document; const scrollbarWidth = window.innerWidth - documentElement.clientWidth; const previousOverflow = body.style.overflow; const previousPaddingRight = body.style.paddingRight; body.style.overflow = 'hidden'; if (scrollbarWidth > 0) { body.style.paddingRight = `${scrollbarWidth}px`; } return () => { body.style.overflow = previousOverflow; body.style.paddingRight = previousPaddingRight; };}
// React: menu components mount exactly while the menu is open, so locking on// mount and releasing on unmount covers the menu's whole lifetime.function useWindowScrollLock() { useEffect(() => lockWindowScroll(), []);}Some menu primitives have a modal mode. Radix and libraries built on it, such as shadcn/ui, lock scroll for you when the menu is modal. Apply the manual lock for a non-modal menu. The docs demos on this site apply this manual lock.
The focused row is the anchor for rename and keyboard commands. Restore focus in a predictable way when a menu opens and closes. Keyboard users must reach the main actions without pointer-only gestures.
Trees emits path-based rename and drop events. Your app decides whether to save those changes. It can save them to a server, local state, or another boundary. Keep this separation clear.
To learn the shared terms behind those events, read Shared concepts. For runtime lookup, read React API or Vanilla API.
Start with the host element. Then move inward. Host styles control the outer
panel. CSS variables control most of the tree appearance.
themeToTreeStyles(...) maps an editor-like theme into the same variable
system. unsafeCSS is the escape hatch when the supported surfaces cannot 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 that you already own. After mount,
getFileTreeContainer() returns that element when runtime code must update it.
CSS variables are the main public styling surface inside the shadow root. Use them before you inject custom CSS.
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 }/>This approach keeps the normal fallback chain in place:
--trees-theme-* variables from theme helpersFor the token families behind those variables, read 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. It also maps the theme to the
--trees-theme-* variables that the tree 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 }/>;This helper gives you matching panel colors, selection colors, search-field colors, and Git-status colors. You do not rebuild the theme system by hand.
densityPass density to useFileTree, preloadFileTree, or the vanilla FileTree
constructor. This option sets row height and spacing together. The keyword form
('compact', 'default', 'relaxed') sets both values at once. The numeric
form keeps the default row height and sets a custom spacing factor. Every
runtime paints --trees-item-height and --trees-density-override onto the
host from the resolved density. The runtimes are vanilla CSR, vanilla SSR, React
CSR, and React SSR. This step keeps the virtualized row height and the painted
row height aligned. Caller-set inline values on the host still win. So you can
override either variable directly for a one-off. Set itemHeight only when you
need a row height that does not match a preset.
The keyword presets are exported as FILE_TREE_DENSITY_PRESETS. So SSR helpers
such as initialVisibleRowCount can divide by the preset row height. They do
not hard-code it.
Use:
themeToTreeStyles(...) when the tree must inherit an editor palettegetFileTreeContainer() in vanilla when runtime code needs the mounted host
elementAdd explicit overrides on top when the imported theme is close but not final.
unsafeCSS is the escape hatchUse unsafeCSS for cases that 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. Do not rebuild the whole visual system from raw selectors. For 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 includes three built-in sets:
minimal for low-noise file and folder visualsstandard for common language and file-type recognitioncomplete for the broadest built-in coveragePass the set name directly when you need only a different baseline.
1234const fileTree = new FileTree({ paths, icons: 'standard',});Built-in sets use semantic icon colors by default. Turn the colors off before you use a sprite sheet. Do this when the product needs a quieter or monochrome look.
1234567const fileTree = new FileTree({ paths, icons: { set: 'complete', colored: false, },});Use the styling system for broader appearance control. Do not treat icons as a parallel theme surface. Read 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', }, },});This approach keeps the built-in mapping for every icon that you did not change.
File-specific rules win over broader rules. Trees resolves icons in this order:
For the full lookup contract, read Icons.
Use spriteSheet in two cases. Use it when you already have branded SVG
symbols. Also use it when you need a few custom symbols next to 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 point remap rules
at those symbols. Do not make sprite sheets the default docs path.
Use built-in gitStatus when the signal is Git-like. Use 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. 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' }, ],});This is the shortest path when the row signal already matches Git semantics.
gitStatus is enoughUse gitStatus only when the meaning is Git-like. Do not invent fake Git state
only to show a badge. Let Trees own the built-in status lane. Let the styling
system control how those signals look.
Replace the current status data directly when the surrounding app changes. Examples are new commits, branches, comparison views, or status visibility.
12fileTree.setGitStatus(nextStatuses);fileTree.setGitStatus(undefined);This step keeps the runtime accurate. It does not promise 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, and short secondary labels.
Use:
gitStatus when the meaning is Git-likerenderRowDecoration for everything elseKeep annotations short and easy to read. Give a decoration accessible text or a tooltip when it affects user decisions.
Keep Git-status colors and decoration visuals in the same appearance system as the rest of the tree. Use the styling and theming controls for color. Do not add a second theme layer for annotations.
For that part of the API, read Style and theme the tree and Styling and theming.
To make large trees fast, first reduce unnecessary client work. Shape and order the data before it reaches the UI. Then tune rendering only where the visible window needs help.
Use:
paths for small demos and low-ceremony casespreparedInput for larger treespreparePresortedFileTreeInput(...) when the server already owns the final
orderThis order matters. Do not go straight to the rendering knobs while the client still does avoidable shape or sort work.
For larger trees, move the shape 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);}Use prepareFileTreeInput(...) instead when you have only an unsorted path
list. In both cases, 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, to budget SSR or first-render work before
measurementdensity, when your design changes the row density enough to matter. Pass
'compact' | 'default' | 'relaxed' or a custom numeric factor. This option
sets both the row height and the spacing in one place. Use itemHeight only
when you need a row height that does not match a preset.overscan, 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. The browser then measures the actual viewport.
Keep the language outcome-focused. The tree mounts the visible slice and a small buffer around it.
Prepared input lowers the cost to shape 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.
This is another reason to start with the input story first. Do not recompute the tree shape in the client while the visible tree also changes often.
Large trees often benefit from server preload. The same scale rule applies. Prepare or presort the input on the server one time. Preload the tree one time. Then let the client hydrate that work. Do not recompute it.
Read SSR when first paint matters. Read 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 does
the jobUse SSR when the tree must arrive from the server with a fast first paint. The tree then becomes interactive on the client. SSR is not a third primary runtime. It is a preload-and-hydrate layer over the same React or vanilla model.
Import the preload helpers from @pierre/trees/ssr. Call preloadFileTree(...)
on the server. 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,});The rendered container still sets the steady-state height.
initialVisibleRowCount is only a first-render hint for SSR and hydration. The
browser then measures the real viewport.
Do not unpack the payload field by field in application code. Pass it forward unchanged.
The server and the client must use the same tree-defining options. Match the
same input source. Match the same id discipline. Match the same
state-affecting options that change the first rendered tree.
The result is a hydration mismatch when the server preloads one tree and the client builds a different one.
React still creates the model with useFileTree(...). The one 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 starts 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 });}Render normally instead of hydrating when that server-rendered container is missing.
In docs and application code, call the preload result an SSR payload or handoff
object. React uses it as preloadedData. Vanilla hydrates existing server
markup. Both flows reuse the same server work. Neither flow teaches the payload
internals as the main story.
Large-tree SSR works best when the server already owns the expensive shape work. Prepare or presort the input on the server. Preload one time. Then let the client hydrate that same result.
The preloaded path uses declarative shadow DOM. In React, the packaged wrapper handles the host ownership details that it needs for hydration. Use the runtime behavior. Do not write 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. They use the same tree-defining inputs. They use the same mutation vocabulary.
Canonical path strings are the public identity for tree items. Use paths for:
Files and directories share the same identity space. The APIs mark 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. Do not build the shape by hand.
This table lists 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: shows only the matches and the ancestor chain that keeps
them navigable. This is the guide default.collapse-non-matches: keeps the matching paths and the ancestor chain
visible. It collapses unrelated branches out of the way.expand-matches: expands the matching branches into the surrounding tree
context. It keeps non-matching rows visible.React and vanilla use the same search modes. Runtime pages must 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:
Read Navigate selection, focus, and search for workflows. Read React API for selector hooks. Read Vanilla API for direct class methods.
dragAndDrop, renaming, and composition are the runtime-agnostic editing
surfaces. They share the same path-first event model. The React and vanilla
integration points differ.
Read Rename, drag, and trigger item actions for the user-facing workflows that build on 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, read Styling and theming and Icons.
initialVisibleRowCount, density, itemHeight, and overscan are rendering
knobs, not onboarding concepts. The rendered container sets the steady-state
height. These options only tune the first-render budget, the visual density, and
the virtualized row window.
For density, use the density keyword ('compact' | 'default' | 'relaxed') or
a numeric factor. It sets both the row height and the spacing in one place. The
React <FileTree> wrapper paints --trees-item-height and
--trees-density-override for you. Use itemHeight only when you need a row
height that does not match a preset.
Read Handle large trees efficiently when scale becomes the real problem.
Trees exposes semantic tree mutations, not DOM events. The shared mutation terms are:
addremovemovebatchresetPathsonMutationMutation payloads stay path-first. Reset events also report whether prepared input was involved. Invalidation metadata reports whether the canonical state or the 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 uses that handoff as preloadedData. Vanilla hydrates existing server
markup through hydrate({ fileTreeContainer }). Neither runtime must teach the
field-by-field payload steps 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 that the vanilla runtime uses.
Start with useFileTree(options). Do not build a large controlled component.
The hook creates one stable model for the component lifetime. Later option
changes do not reconfigure the model. 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 modelUse model methods when the tree changes after mount. The methods include
resetPaths(...), setComposition(...), setGitStatus(...), setIcons(...),
and the search or mutation methods.
<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 during hydrationheader and renderContextMenu add React rendering onto the model
composition surfaceReact components read from the model through selector hooks. React components 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 that the vanilla runtime exposes.
Typical write paths are:
scrollToPath(...), which reveals a path without moving DOM focus and makes
optional model focus changes.focusFirstItem(), focusLastItem(),
focusNextItem(), focusPreviousItem(), and focusParentItem().getFocusedIndex(), getVisibleCount(), and
getVisibleRows(start, end) when surrounding UI needs custom navigation.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 composition options is in Shared concepts.
Use selector hooks when React UI needs reactive reads. Use
model.onMutation(...) when you need semantic side effects. Examples are
persistence, logging, or analytics around add, remove, move, reset, or batch
operations.
The React runtime uses server preload through preloadedData on <FileTree />.
The model still comes from useFileTree(...). 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, read Styling and theming and
Icons.
The vanilla runtime lives at the package root, @pierre/trees.
new FileTree(...) is 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 must create and append the
host for youhydrate({ fileTreeContainer })
unmount()
cleanUp()
getFileTreeContainer()
Common read methods are:
getItem(path)getFocusedItem()getFocusedPath()getFocusedIndex()getVisibleCount()getVisibleRows(start, end)getSelectedPaths()getComposition()isSearchOpen()getSearchValue()getSearchMatchingPaths()These reads stay path-oriented and model-oriented. They do not ask you to infer the tree state from DOM order.
getItem(path) returns 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)focusFirstItem() / focusLastItem()focusNextItem() / focusPreviousItem()focusParentItem()scrollToPath(path, { offset: 'top' | 'center' | 'nearest', focus?: boolean })startRenaming(path?)scrollToPath(...) scrolls the virtualizer without moving DOM focus. It updates
model focus by default. Pass focus: false to keep model focus unchanged.
The traversal methods follow the current visible tree, including its active
expansion and search state. getVisibleRows(start, end) uses inclusive row
indexes and returns path-first row metadata for building custom navigation.
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 must refresh a derived snapshot.
Use onMutation(...) when add, remove, move, reset, or batch intent matters.
This separation keeps model subscriptions for state reads. It keeps mutation events for semantic side effects.
The composition surface covers:
setComposition(...)Read Rename, drag, and trigger item actions for workflow-level guidance.
This page names only the vanilla hydration entry point:
hydrate({ fileTreeContainer }). The full server-preload-first contract is 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 into a
full host-markup string.
Use it when your server integration needs a serialized HTML string. This avoids a framework-specific render boundary. The payload still stays opaque at the docs level.
Treat the preload result as a handoff object. Do not treat it as a field-by-field integration surface.
Docs can name the runtime touchpoints that use it:
preloadedDatahydrate({ fileTreeContainer })Do not make the raw payload fields the public story.
The server and the client must use the same tree-defining options.
Match:
paths, prepared input, or presorted prepared input)id disciplineA mismatch is not a supported partial merge. It produces a hydration mismatch or an 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 it 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. Then it creates
new FileTree(options) on the client. Then it calls
fileTree.hydrate({ fileTreeContainer }) against the existing host.
For the full runtime surface, read Vanilla API. For the workflow guide, read SSR.
Keep any note about declarative shadow DOM or hydration warnings short and secondary. This page owns the public handoff contract, not the 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() returnsHost styles own width, height, borders, background, and panel placement. CSS variables control the UI inside that host.
The tree styling surface uses variable families, not one flat list.
Core families include:
Trees resolves styling in this order:
--trees-*-override--trees-theme-* variables from theme helpersSo themeToTreeStyles(...) fills the middle layer. Direct overrides still win.
themeToTreeStyles(...)themeToTreeStyles(theme) maps a VS Code or Shiki-style theme object into host
styles. It also maps the theme to the --trees-theme-* variables that the tree
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 customization.
Reference topics:
Keep it narrow. Do not make it 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' turns off the built-in file-type mappings. File rows still fall
back to the generic file icon. Remaps can replace that icon.
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, and for
multi-part suffixes such as spec.tsFile icon lookup resolves in this order:
byFileNamebyFileNameContainsbyFileExtension, and the more specific suffix winsSo spec.ts wins over ts when both rules exist.
RemappedIcon shapeRemappedIcon supports two forms:
name, and 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.
Turn off the built-in colored icons with colored: false. The token lookup and
the fallback precedence for icon colors are in
Styling and theming.