Zero runtime dependencies · ESM + CJS · MIT

@grida/tree-view

Headless, agnostic tree-view controller for editors and IDEs — a pure state machine plus a small set of helpers, rendered with whatever framework you want. The showcases below are the same TreeController wired to different surfaces — canvas, explorer, document, desktop.

Grida
Grida

The real @grida/svg-editor drives the canvas. The layers panel is a tree-view bound to that editor's tree — select or hover on either side and the other follows.

SVG

Click a shape · drag handles · ⌫ to delete

Figma
Figma

Same editor, same bridge — only the panel chrome and the editor's chrome color change. The layers panel still walks the editor's tree.

LayersCover
SVG

Click a shape · drag handles · ⌫ to delete

VS Code
VS Code

Filesystem semantics: drops resolve into the nearest folder. Selecting a file opens it — selection is the only wire to the editor pane.

Explorer
src
components
Button.tsx
Card.tsx
app
page.tsx
layout.tsx
globals.css
lib
utils.ts
api.ts
index.ts
public
favicon.ico
vercel.svg
package.json
tsconfig.json
README.md
@grida/tree-view
Select a file in the explorer to open it
Show All Commands⇧⌘P
Go to File⌘P
Find in Files⇧⌘F
Toggle Terminal⌃`
Open Settings⌘,
VS Code
VS Code (async)

Same controller, lazy children. Expand a folder to fire listChildren; collapse mid-load aborts via the AbortSignal the provider receives. Errors land on a side-channel getLoadState — your meta stays clean.

Explorer · async
What this is

Same TreeController as the static VS Code section above. Children are listed async via@grida/tree-view/async. Expand a folder → spinner; collapse mid-load → abort; toggle Fail next → error row with retry; press Emit watcher event → tree mutates without re-listing.

Latency · 600 ms
handle.getLoadState(focused)
(focus a row)
Producer interface: hasChildren + listChildren(id, signal) + subscribeChanges?. The same shape works for fs.promises.readdir, an HTTP endpoint, or OPFS.
Notion
Notion

Workspace sidebar with nested pages, emoji affordances, and drag-into-page. Selecting a page swaps the document on the right — one controller, one selection channel.

softmarshmallow's Notion
👋Getting Started
🏡Personal
Tasks
📝Notes
📚Reading list
👥Team
🛠Engineering
🗓Sprint planning
🚨On-call runbook
🎨Design
📋Product
🗂Templates
Workspace/Notes
Share•••
📝

Notes

Pages live in the sidebar — selecting one opens it here. Drop a page onto another to nest; drag between two pages to reorder. The sidebar and this document are wired to the same TreeController.

💡The tree-view package never reads your page content. The doc you’re reading is a static mock — only the selection wires through.

Today

  • ☐ Triage CodeRabbit comments on PR 719
  • ☐ Wire F12 reversed+desiredDepth test
  • ☑ Reverse-aware drag math (F10)
  • ☑ Tolerant expandTo (F11.1)
Finder
Finder

Multi-column rows, zebra striping, double-click to expand — the same core, dressed as macOS and dropped onto the desktop.

Finder
Mon 9:41 AM
softmarshmallow
Name
Documents
grida
README.md
Notes.md
Resume.pdf
Screenshot.png
Downloads
Installer.dmg
Trailer.mp4
Applications
Figma.app
Visual Studio Code.app
Grida.app
FinderSafariMessagesMapsNotesRemindersFreeformMusicLogic ProXcodeVS CodeGridaTrash

Quick start

One source, one controller, one provider. The package owns expansion, selection and drag math; you render the rows with whatever markup you want.

Built for editor scale.

The state machine, the math, the intents — packaged so adopters ship a tree in a day, not a quarter.

Canvas layers

Canvas layers

Reverse-children flatten, group highlight, selection-aware drag — the controller already knows what a design tool's layers panel needs.

Dark layers panel

Dark layers panel

Same controller, themed for any palette. Compose constraints to enforce frame / component / instance semantics.

File explorer

File explorer

Filesystem drag rule — drops resolve into the nearest folder. Drop-target highlight cascades through descendants.

Workspace sidebar

Workspace sidebar

Emoji-prefixed pages, nested toggles, drop-into-page. Selection wires the document body in three lines.

Native window

Native window

Multi-column rows, zebra striping, double-click-to-expand — all consumer-side; the package never touches the markup.

Examples

Selection, drag, keyboard, virtualization, custom sources.

1. Plain hierarchy

Expand / collapse + single-select. Click a chevron to toggle, click a row to select.

Hero Frame
Background
Content
Heading
CTA Button
Mask group
Rectangle 1
Rectangle 2
Locked layer
Stray rectangle
Caption

2. Multi-select

Replace (click), toggle (Cmd/Ctrl + click), range (Shift + click or Shift + ArrowUp/Down).

Hero Frame
Background
Content
Heading
CTA Button
Mask group
Rectangle 1
Rectangle 2
Locked layer
Stray rectangle
Caption
(none)

3. Keyboard navigation

Left panel: defaultKeymap installed (arrows + Home/End + Enter → rename intent + Delete → delete intent). Right panel: the graphics-tool subset — arrow keys are not bound, so they pass through to the host (in a real editor, they would nudge the canvas selection).

defaultKeymap
Hero Frame
Background
Content
Heading
CTA Button
Mask group
Rectangle 1
Rectangle 2
Locked layer
Stray rectangle
Caption
graphics-tool subset (no arrows)
Hero Frame
Background
Content
Heading
CTA Button
Mask group
Rectangle 1
Rectangle 2
Locked layer
Stray rectangle
Caption

4. Move constraints

allOf(onlyIntoContainers(), disallowDescendant()). Drag any row onto a leaf row: the drop is coerced to after. Drag a container onto itself or its descendant: the drop is refused.

Hero Frame
Background
Content
Heading
CTA Button
Mask group
Rectangle 1
Rectangle 2
Locked layer
Stray rectangle
Caption
no intent yet

5. Move vs. copy drag

Drag a row to reorder. Hold Alt (Option on macOS) to switch the active drag to copy. Both intents are visualized below without mutating the source tree.

Hero Frame
Background
Content
Heading
CTA Button
Mask group
Rectangle 1
Rectangle 2
Locked layer
Stray rectangle
Caption
Last intents (newest first). Hold `Alt`/`Option` while dragging to flip to `copy`.

    6. Virtualized (~10,000 rows)

    Demonstrates the recipe documented in the README: the package ships a stable flat row list; the demo wires it into @tanstack/react-virtual. The virtualizer is a consumer choice, not a runtime dependency of @grida/tree-view.

    ~10,000 rows total, every group pre-expanded. Scrolling renders only the visible window.

    7. Virtualized + deeply nested

    100 chains × depth 100 = 10,000 rows, max indent at depth 99 (≈ 1,188 px from the row's left edge). The virtualizer handles row count; horizontal scroll is a pure consumer-side choice — the panel sets a min-width on the inner virtual canvas so the container scrolls both axes. Without that, indented rows would just truncate at the right edge.

    10,100 rows, max depth 100. The row is split into an indent spacer and a position: sticky content cluster — the cluster floats at the right edge of the visible viewport until the indent scrolls far enough that the natural position catches up (Figma layers-panel pattern).

    8. Custom data source

    A JSON tree adapted to TreeSource without copying — proves the package is data-agnostic.

    Page 1
    Background
    Group A
    Card
    Title
    Photo
    Recipes

    Common features, idiomatic wiring.

    Inline rename, focus restoration after delete, type-ahead, reveal in tree, external drag, decoration overlays, persisted expanded state — the patterns every real layer panel or file explorer needs.

    Inline rename

    Focus a row, press Enter or F2. The package emits a rename intent; you mount the input and commit the new label to your source. Pass keymap={editing ? null : defaultKeymap} while editing so Enter commits the input instead of re-firing rename.

    Hero Frame
    Background
    Content
    Heading
    CTA Button
    Mask group
    Rectangle 1
    Rectangle 2
    Locked layer
    Stray rectangle
    Caption

    Focus a row, hit Enter (or F2) to rename. The package emits a rename intent; the consumer mounts the input.

    Multi-select drag rule

    Figma / VS Code / Finder convention: if the grabbed row is part of the current selection, drag the whole selection; otherwise drag just the row. One line in the pointer-down → startDrag bridge: sel.includes(grabbedId) ? sel : [grabbedId].

    Hero Frame
    Background
    Content
    Heading
    CTA Button
    Mask group
    Rectangle 1
    Rectangle 2
    Locked layer
    Stray rectangle
    Caption

    Cmd/Ctrl-click two or three rows, then drag any of them. The intent below shows items = full selection. Drag an unselected row — items = just that row.

    no drag yet

    Focus restoration after delete

    When you remove the focused row(s), focus should jump to the next visible sibling (or previous, or parent). nextFocusAfterRemove(rows, ids) picks the target from a pre-removal row snapshot — five lines on the consumer side.

    Hero Frame
    Background
    Content
    Heading
    CTA Button
    Mask group
    Rectangle 1
    Rectangle 2
    Locked layer
    Stray rectangle
    Caption

    Click a row, then press Delete. Focus jumps to the next visible row (or previous if at the end). Multi-select with Shift then Delete to remove a range — focus lands on the row after the range.

    Type-ahead search

    Type a letter (or a sequence within ~500 ms) to jump focus to the first row whose label starts with the buffer — the WAI-ARIA tree pattern. findByLabelPrefix(rows, prefix, opts) handles the wrap-from-focus search; you keep the buffer (a short-lived string with an inactivity reset).

    Hero Frame
    Background
    Content
    Heading
    CTA Button
    Mask group
    Rectangle 1
    Rectangle 2
    Locked layer
    Stray rectangle
    Caption

    Focus the panel (click on it), then type he to jump to "Heading", m to jump to "Mask group", etc. Re-typing the same first letter cycles matches.

    Reveal-in-tree

    "Go to file" / "Find in selection": expand ancestors, focus, select, and scroll into view. controller.reveal(id, opts?) covers the first three; DOM scrollIntoView is yours (the controller has no DOM handle).

    Stray rectangle
    Caption

    Click any button — the panel starts fully collapsed. The recipe is expandTo(id) → focus(id) → scrollIntoView in 4 lines.

    Drag from outside (palette → tree)

    Drag a chip from a side palette into the tree to create a new node. External payloads don't go through the controller's drag state (today); the consumer runs its own pointer loop and inserts into the source on drop. A first-class startExternalDrag API is on the roadmap.

    Hero Frame
    Background
    Content
    Heading
    CTA Button
    Mask group
    Rectangle 1
    Rectangle 2
    Locked layer
    Stray rectangle
    Caption

    Drag a chip from the palette into the tree. Drop near the top of a row for before, the bottom for after, the middle of a container for into. The recipe rebuilds hit-test from scratch because startDrag requires existing node ids — that's the SDK gap.

    Decoration overlay

    Badges (git status, problem counts, dirty markers) come from stores that change independently of the tree. Keep them in consumer-side state and read them in the row renderer — so shuffling badges never bumps source.getVersion() or invalidates the row list.

    Hero Frame
    Background
    Content
    HeadingM
    CTA ButtonU
    Mask group
    Rectangle 1A
    Rectangle 2
    Locked layer
    Stray rectangle
    Caption

    Decorations live in consumer React state — never touched by TreeSource.getVersion(). Shuffling badges does not invalidate the controller's row list.

    Controlled expanded set (persist to localStorage)

    Expand / collapse state survives reload — hydrate from storage on mount, persist on every notify. getExpanded() / setExpanded(ids) and the expanded subscription channel are all the controller needs.

    Hero Frame
    Background
    Stray rectangle
    Caption

    Expand / collapse a few rows, then reload the page — state is persisted to localStorage. The controller already exposes getExpanded() /setExpanded() / the expanded channel; the recipe is two effects.

    Persisted: (none)

    Guides overlay (opt-in)

    Default trees have no indent rails. When the consumer wants them — as a continuous rail through descendants of a special container (a mask group, a boolean op, etc.) — the rail is drawn as a single SVG overlay layered over the tree, not as per-row pieces. This keeps the line continuous across any row padding/gap and lets the consumer pick the symbol.

    Hero Frame
    Background
    Content
    Heading
    CTA Button
    Mask group
    Rectangle 1
    Rectangle 2
    Locked layer
    Stray rectangle
    Caption