Overview

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.

Guides

@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.

Choose your integration

1. Path-first identity

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.

2. React vs Vanilla JS

Use the React entry point when your UI is already in React. For more information, read the getting started with React guide.

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.

3. Understand tree-shape

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.

Get started with React

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.

Install @pierre/trees

Use the package root for the vanilla runtime. Use the /react entry point for the React wrapper.

Create the model with useFileTree(...)

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.

Read Shape tree data for fast rendering after this quickstart if you must still decide how to shape that input.

Render with <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.

Read and update tree state through the model

React code reads snapshots from the model through selector hooks. React code writes back through model methods.

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.

Use simple paths input only when the tree is small

Raw 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.

Move to prepared input before the tree gets expensive

Use this recommended scale path:

  1. Load canonical paths on the server or another non-UI boundary.
  2. Prepare the tree input one time.
  3. Pass 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.

Add SSR later when first paint matters

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.

Get started with vanilla

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.

Install @pierre/trees

Create the model with new 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.

Render into a host element

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.

Read and update tree state through the model

The instance gives you direct read methods, item handles, and imperative controls.

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.

Use simple paths input only when the tree is small

Raw 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.

Move to prepared input before the tree gets expensive

Use this recommended scale path:

  1. Load canonical paths outside the UI.
  2. Prepare the tree input one time.
  3. Construct 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.

Add SSR later when server rendering matters

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.

Advanced note: wrapping the vanilla model in another framework

Keep the same ownership boundary when you wrap the vanilla model in another framework:

  • create and own the FileTree instance from that framework's lifecycle
  • mount or unmount around the instance
  • keep all tree reads and writes on the model

This approach keeps React as the only first-class wrapper surface. It does not change how the model works.

Shape tree data for fast rendering

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.

Start with server-prepared input for scalable trees

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.

The client still uses the same path-first model. Only the expensive preparation step moves earlier.

Pass preparedInput into the runtime

Both primary runtimes use the same prepared payload shape.

Use simple paths input only for small trees

Raw paths is still the right choice for small demos, tests, and very small static trees.

This is the easy start path, not the scale-oriented default. Move the preparation work out of the client when the tree grows.

Presorted input is the highest-performance prepared-input path

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.

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.

Prepare on the server, render on the client

Use this simple split:

  1. Load canonical paths outside the UI.
  2. Prepare the input one time.
  3. Pass 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.

Keep client-side sorting and preparation secondary

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.

Start 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.

Use useFileTreeSelector(...) when sibling UI needs a custom derived snapshot.

Vanilla code connects the same behavior to the class instance.

The DOM host is not the source of truth. The model is the source of truth.

Rename, drag, and trigger item actions

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.

Start with direct row actions

The core row workflows are:

  1. rename in place
  2. drag and drop
  3. optional context-menu commands

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.

Rename items in place

Enable renaming when users must rename rows inline. The common policy hooks are:

  • canRename(item) to block protected files or folders
  • onRename(event) to respond to the final path change
  • onError(error) to show invalid rename attempts

The rename event reports three values. It reports the source path. It reports the destination path. It reports whether the item was a folder.

Move items with drag and drop

Enable dragAndDrop when users must move files or folders directly in the tree. The practical hooks are:

  • canDrag(paths) to lock specific paths
  • canDrop(event) to reject invalid destinations
  • onDropComplete(event) for persistence or adjacent UI updates
  • onDropError(error, event) for visible failures

Drop callbacks report the dragged paths and the resolved target shape. They do not ask you to find the DOM rows yourself.

Combine rename and drag safely

A common editable project tree enables both renaming and dragAndDrop. Then it adds policy guards for protected paths.

Typical rules include:

  • block rename for root config files such as package.json
  • reject drops into generated directories such as dist/
  • show rename and drop failures outside the tree; do not fail silently

Add a context menu as an optional command surface

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:

In vanilla, use composition.contextMenu.render to supply the menu element directly from the runtime config.

Lock window scroll while a menu is open

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:

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.

Keep keyboard and focus behavior predictable

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.

The tree owns the interaction surface. Your app owns persistence.

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.

Style and theme the tree

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.

Start with host styling

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={...} />.

In vanilla, style the mounted host element that you already own. After mount, getFileTreeContainer() returns that element when runtime code must update it.

Use CSS variables for most visual changes

CSS variables are the main public styling surface inside the shadow root. Use them before you inject custom CSS.

This approach keeps the normal fallback chain in place:

  1. explicit override variables
  2. --trees-theme-* variables from theme helpers
  3. library defaults

For the token families behind those variables, read Styling and theming.

Match an editor palette with 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.

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.

Set density with density

Pass 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.

Choose the right styling layer

Use:

  • host styles for layout and panel framing
  • CSS variables for product-specific appearance changes inside the tree
  • themeToTreeStyles(...) when the tree must inherit an editor palette
  • getFileTreeContainer() in vanilla when runtime code needs the mounted host element

Add explicit overrides on top when the imported theme is close but not final.

unsafeCSS is the escape hatch

Use unsafeCSS for cases that the supported host and variable surfaces cannot express. Keep it small, local, and secondary.

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.

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.

Start with the built-in icon sets

Trees includes three built-in sets:

  • minimal for low-noise file and folder visuals
  • standard for common language and file-type recognition
  • complete for the broadest built-in coverage

Pass the set name directly when you need only a different baseline.

Adjust color mode before you remap icons

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.

Use the styling system for broader appearance control. Do not treat icons as a parallel theme surface. Read Style and theme the tree.

Use the object form for targeted remaps

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 lock
  • byFileName for exact basenames such as package.json
  • byFileExtension for suffixes such as ts or spec.ts
  • byFileNameContains for broader patterns such as dockerfile

This approach keeps the built-in mapping for every icon that you did not change.

Rule precedence matters

File-specific rules win over broader rules. Trees resolves icons in this order:

  1. exact basename matches
  2. basename-contains matches
  3. extension matches, and the more specific suffix wins
  4. the chosen built-in set
  5. the generic file-slot remap or fallback

For the full lookup contract, read Icons.

Use a sprite sheet only for advanced cases

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.

Keep the contract simple. Provide <symbol> definitions. Then point remap rules at those symbols. Do not make sprite sheets the default docs path.

Show Git status and row annotations

Use built-in gitStatus when the signal is Git-like. Use renderRowDecoration when the row needs product-specific metadata that is not Git state.

Start with built-in gitStatus

gitStatus is the default row-signal path. It attaches statuses to canonical paths. Folders can reflect changed descendants automatically.

Trees supports these built-in statuses:

  • added
  • modified
  • deleted
  • ignored
  • renamed
  • untracked

This is the shortest path when the row signal already matches Git semantics.

Know when gitStatus is enough

Use 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.

Update status sets over time

Replace the current status data directly when the surrounding app changes. Examples are new commits, branches, comparison views, or status visibility.

This step keeps the runtime accurate. It does not promise a filesystem watcher or a repo-sync framework.

Add custom row annotations with renderRowDecoration

Use renderRowDecoration when the row needs metadata that is not Git-like.

Good fits include generated-file markers, remote-storage indicators, validation markers, and short secondary labels.

Choose between the two paths

Use:

  • gitStatus when the meaning is Git-like
  • renderRowDecoration for everything else
  • both together when a row needs Git state and one extra product-specific signal

Keep annotations short and easy to read. Give a decoration accessible text or a tooltip when it affects user decisions.

Keep styling separate from annotation meaning

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.

Handle large trees efficiently

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.

Start with the main recommendation

Use:

  • raw paths for small demos and low-ceremony cases
  • preparedInput for larger trees
  • preparePresortedFileTreeInput(...) when the server already owns the final order

This order matters. Do not go straight to the rendering knobs while the client still does avoidable shape or sort work.

Prepare input before it reaches the client

For larger trees, move the shape work out of the UI. Prepared input is the scale-oriented public path.

Use prepareFileTreeInput(...) instead when you have only an unsorted path list. In both cases, pass the prepared result into React, vanilla, or SSR hydration.

Keep rendering work small

Trees already virtualizes the visible row window. Most apps do not need custom virtualization primitives.

The main rendering knobs are:

  • the host element CSS height, because layout sets the steady-state viewport size
  • initialVisibleRowCount, to budget SSR or first-render work before measurement
  • density, 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 scrolling

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.

Expansion and search still affect scale

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.

SSR and hydration pair naturally with prepared input

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.

Common pitfalls

Avoid these patterns when the tree grows:

  • you send only raw paths for huge server-known datasets
  • you re-sort on the client after the server already chose the order
  • you use low-level virtualization ideas as the first fix
  • you rebuild the model when resetPaths(...) with matching prepared input does the job

SSR

Use 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.

Start with the server step

Import the preload helpers from @pierre/trees/ssr. Call preloadFileTree(...) on the server. Treat the result as one opaque handoff object.

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.

Core invariants

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 flow

React still creates the model with useFileTree(...). The one difference is that the wrapper receives the opaque handoff object as preloadedData.

The model stays primary. preloadedData only starts hydration.

Vanilla flow

Vanilla uses the same preload step. But the client hydrates the server-rendered container that is already in the page.