@grida/tree-view

Headless, agnostic tree-view controller for editors and IDEs. Zero runtime dependencies, no DOM coupling in the core, no widget library on top. React is the only optional peer.

npmGitHubpnpm add @grida/tree-view
Themed showcase

Same controller. Wildly different trees.

The package never touches your DOM. Each panel below is identical wiring — same TreeController, same drag / keyboard / hit-test loop — composed against a different fixture, row renderer, indent geometry, and constraint stack. The result: a layers panel, a sidebar, a file explorer, and a double-click-to-open list view, all from one ~500-line core.

Grida

Monochrome zinc accent, eye + lock per row, full reorder drag.

Home
Hero section
Design without limits
Subheading
Get started →
hero-illustration.svg
Features
Card / Canvas
Card / Forms
Card / Database
Footer
Pricing
Assets
logo.svg
wordmark.svg

Figma

Dark layers panel, components in purple, hidden layers dim, full reorder drag.

LayersPage 1
iPhone 15 — 393×852
iPhone Frame
Status Bar
Content
Card 1
Cover.png
Daily Mix
Card 2
Tab Bar (locked)
Button / Primary
Background
Label
Button / Primary
Star

VS Code

Filesystem semantics — drop is always *into* the nearest folder, target highlights in blue. No reordering.

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

Finder (macOS)

Multi-column grid, zebra rows, double-click to expand. Same FS drag rule as VS Code.

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

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
    Patterns & 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. Each panel below shows how to wire the feature with the primitives the package ships.

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

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

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

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

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

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

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

    17. 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)

    18. 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 (vertical bar, ┌/└ corners, arrow markers, anything).

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