PanelLayout
An IDE-style tiling workspace. Panels split, resize, drag onto each other's edges to re-dock, and merge into tab groups — all over a recursive tree the host can read, write, and persist.
It is view-agnostic by construction. A panel carries a view string that
only your renderBody / renderHeader interpret; the layout never reads it.
Everything a real app needs on top — which views may be tabbed, which one
reclaims space when a panel closes, which panels stay transparent — is injected
through props rather than inferred.
Three layers ship separately, and the lower two are useful on their own:
| Layer | What it is |
|---|---|
panel-tree | The pure tree — nodes, migration, mutators. No React, no DOM. |
panel-box | Panel geometry as CSS math, published as --panel-x / --panel-w. |
PanelLayout | The renderer plus the pointer and keyboard wiring. |
Declarative requests and agent control
Use the layout controller to address named destinations, select panels by geometry or content, and apply pin-aware spawn requests. It provides inspect → resolve → apply above these low-level mechanics, with forkable examples.
Demo
Drag a divider to resize. Grab the handle at the top of a panel header and
drop it on another panel: near an edge it docks to that side, in the centre it
merges as a tab. ⌘D splits the focused panel to the right, ⌘⇧D below.
Escape cancels a drag in flight.
PanelLayout fills its container. Without an ancestor that has a real height it collapses to
nothing — the demos here sit in a fixed-height box.
Tab groups
A group is a flat tab bar of panels sharing one region. By default, only the
active tab is mounted. Enable tabbed to keep each view mounted and connected
while switching tabs or docking it into another region. Groups normally arise from a user dropping one panel onto another's
centre, so panelGroup is how you seed a layout that already has tabs. A tab
can be dragged straight out of its group to dock elsewhere, and a group that
loses its second-to-last tab collapses back into a plain panel.
Persistent editor and preview views
Use tabbed for editors, collaborative documents, and iframe previews that must
retain their own state when hidden or moved. Each leaf keeps one connected body;
inactive bodies are hidden. Closing a leaf disposes its body.
A singleton uses the same header row without an extra tab strip. Multiple views show tabs beside the active view's header controls. Custom labels stay on one line and clip when they reach those controls.
onRequestClose delegates user close actions to the host. When supplied, the
host must call closeLeaf after its save/discard guard permits disposal.
showSingleTab can opt individual singleton leaves into tab presentation.
Calling focusLeaf(id) also activates that leaf when it belongs to a group.
Keep resource identity keyed by leaf ID; hiding or moving a view must not replace
its document or authorization context.
Singletons
Some views should never be tabbed — a settings pane, a connection status, a
sessions list you want exactly one of. isTabbable marks them: the centre drop
zone is suppressed whenever either side of a merge is a singleton, so the live
drag preview and the actual drop always resolve to the same edge.
Drag Settings onto the editor's centre below — it docks to an edge instead of merging. Drag Editor and Terminal onto each other and they tab.
Panels that stay
A panel can be the SUBJECT of a screen rather than an auxiliary view — the note you are reading, with reference panels beside it. Closing that one would leave a window of satellites with nothing to orbit.
closable refuses it. The header's close button goes, a tab's × goes, and
Delete on that tab does nothing. It is a policy rather than a class hook
precisely because hiding the button is not enough: Delete never touches one, so
the refusal lives next to the close handler and the affordances just reflect it.
The imperative closeLeaf is deliberately NOT gated — a host taking a panel
away itself already knows its own policy, and would otherwise have to flip the
predicate first just to clean up after a route change.
Filling the panels
renderBody and renderHeader are the only place a view string means
anything. A custom header replaces the default dot-and-title, but the drag
handle and close button stay — they are chrome the layout always owns.
primaryView names the panel that reclaims space: close Terminal below and
Editor absorbs the whole freed slot instead of every sibling growing a little.
Driving it from code
The ref handle mutates the same tree the user does, and onChange reports every
mutation from either source.
Persisting a layout
Save on onChange, restore in initial. usePersistedPanelLayout is both
halves, with the parts that are easy to get wrong already handled.
Saved layouts are wrapped in a { v, root } envelope, which has no top-level
kind. Shape-check the raw blob first and you reject every layout saved under
the current format — silently wiping the user's layout on upgrade.
unwrapLayout handles both the envelope and a bare tree from before it
existed; normalizeTree migrates whatever comes out.
| Option | Type | Default | Description |
|---|---|---|---|
key | string | — | Storage key. Namespace it per user or workspace. |
fallback | () => PanelNode | — | Tree to use when nothing usable is saved. |
storage | Storage | localStorage | Any Storage — e.g. sessionStorage. |
validate | (root) => boolean | — | Last word on a restored tree, run after migration. |
transform | (root) => PanelNode | null | — | Narrow what gets saved — pair with scopeSubtree. |
debounceMs | number | 150 | Write coalescing window. 0 writes immediately. |
Returns { initial, onChange, clear, reset }. A pending write is flushed on
unmount and on pagehide, so the last drag of a session isn't lost.
initial RESTORES a layout, so wiring the toolbar's Reset to it hands back the very layout the
user is clearing. reset() is the other half — it forgets the saved layout and returns the
fallback. Pass it as resetTo, or call it yourself and give the result to replaceRoot.
Scoped layouts
"The layout is per-document and swaps when you switch documents" falls apart the moment app-wide chrome shares the same tree: a connection panel or a settings pane either duplicates into every document's saved layout, or vanishes when you open a document whose layout predates it.
Give each panel an owner. scopeSubtree then extracts just the part one owner is
responsible for, and tagOwnerScopes stamps the scope into the tree before you
save it — so a later reader honours what was saved rather than re-deriving it
from a rule that has since changed.
pruneLeaves(node, keep) is the primitive underneath, and is useful on its own
whenever you need to drop panels and be left with a tree that still renders: a
split keeps the sizes of its survivors (renormalized), a split down to one child
is replaced by that child, and a group collapses by the group's rules — a stale
active tab is re-clamped rather than left dangling.
Opening a view
resolveViewSplit answers "where should this view open?" with no DOM and no side
effects, so the decision is testable on its own. It returns { skip: true } when
the view is already open, which is what makes an open IDEMPOTENT — a deferred
open that re-runs after the panel exists is a no-op rather than a second copy.
A remembered pixel width is converted to a fraction of the target's current width, so a restored width behaves at any window size; an unmeasurable target drops the fraction instead of dividing by zero.
Keyboard and accessibility
| Where | Keys |
|---|---|
| Any panel | ⌘/Ctrl D split right · ⌘/Ctrl ⇧D split down (see splitShortcut) |
| Tab bar | ← → move between tabs (selection follows focus) · Home End first/last · Delete close |
| Divider | ← → (or ↑ ↓) nudge · PageUp PageDown coarse · Home End to the minimum either side |
| Drag in flight | Escape cancels |
The tab bar is a real tablist: tabs are <button>s carrying aria-selected
and aria-controls, the panel they control is the matching tabpanel, and the
strip is a single tab stop with focus roving inside it. Each divider is a
focusable separator reporting aria-valuenow/min/max, and the keyboard runs
through the same clamp as the pointer, so no key can push a panel below its
minimum.
Dragging a panel onto another's edge — or a tab out of its group — has no keyboard equivalent. The split shortcut and the imperative handle cover creating and closing panels; rearranging them needs a pointer.
Panel geometry without measuring
Content inside a panel sometimes needs to know where that panel is — a column
centered on the viewport rather than on its panel, a header that must line up
with the body beneath it. Measuring with a ResizeObserver is the obvious way
and the wrong one: the callback fires after layout and commits a frame late, so
during any animation that moves the panel the content chases an edge that has
already moved, and visibly shakes.
It never needs measuring. A panel's box is pure arithmetic over the panel area
and the split ratios above it, so each level hands its children a calc()-able
expression, which every leaf publishes as --panel-x and --panel-w. The
browser resolves it in the same layout pass as everything it depends on, every
frame, for free.
The contract is that the host declares --panel-area-x / --panel-area-w on
(or above) the element it mounts the layout into, as real lengths — typically a
calc() of the surrounding chrome, e.g. 100vw - var(--sidebar-w). Pass
rootBox to use different variable names. The demos on this page don't declare
them — nothing here reads --panel-x, so it stays inert.
Server rendering
Node ids come from module-level counters, so a tree built during SSR and again
on the client will not agree. Mount the layout client-only, or hand it a tree
whose ids are fixed. For the same reason, build trees inside the initial
callback rather than at module scope.
Each layout scopes its DOM lookups to its own root, so several on one page stay independent — a drag in one can never dock into another.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
initial | () => PanelNode | 3-panel demo | Builds the initial tree. Called once, on mount. |
initialSingle | boolean | false | Without initial: true starts with one panel. |
root | PanelNode | — | Controlled mode. The layout renders this and never mutates its own state; every change arrives via onChange. |
onChange | (root: PanelNode) => void | — | Fires on every tree mutation — split, close, resize, dock, reset. The hook for persistence. |
onResize | (root: PanelNode) => void | — | Fires only on a divider drag — the one signal that expresses "I want this panel at this size". |
renderBody | (leaf: LeafNode) => ReactNode | placeholder | Renders a panel's content. Dispatch on leaf.view. |
renderHeader | (leaf: LeafNode) => ReactNode | dot + title | Replaces the header's centre content. |
isTabbable | (view?: string) => boolean | every view | Return false to make a view a singleton that can never be tab-merged. |
closable | (leaf: LeafNode) => boolean | every panel | Return false to withhold a panel's close affordances and refuse those closes. The handle's closeLeaf is not gated by it. |
primaryView | string | — | The view that absorbs a freed slot when a sibling closes. Default: every sibling grows proportionally. |
leafClassName | (leaf: LeafNode) => string | undefined | — | Extra classes on a panel root. Appended, so bg-transparent overrides the default opaque fill. |
headerClassName | (leaf: LeafNode) => string | undefined | — | Extra classes on a panel header band. Appended. |
headerContentClassName | (leaf: LeafNode) => string | undefined | overflow-hidden | Replaces the overflow policy on the custom-header wrapper. |
rootBox | PanelBox | --panel-area-x/-w | The box the tree's root occupies, as CSS math. |
contentColumnHeaderClass | string | — | Class for the header of a panel that has both a custom header and a contentMax — the hook for a viewport-centered content column. |
palette | readonly string[] | 8 tints | Tints for placeholder panels and the drag surrogate. |
splitShortcut | false | { key?: string } | { key: 'd' } | ⌘/Ctrl + key splits the focused panel (⇧ = vertical). false removes the binding. |
showToolbar | boolean | false | Show the built-in toolbar (title, hints, Reset). |
toolbar | ReactNode | — | Replace the toolbar's contents. Implies showToolbar. |
resetTo | () => PanelNode | initial | What the toolbar's Reset builds. Point it at a fresh tree whenever initial restores one. |
className | string | — | Extra classes on the root element. |
Handle
ref exposes the imperative API.
| Method | Description |
|---|---|
splitWith(leafId, dir, view?, title?, before?, size?, contentMax?) | Split a panel, seeding the new one. size is auto-computed for row splits when omitted. |
getRoot() | The current tree. |
replaceRoot(tree) | Swap the whole layout. Migrates the incoming tree and reseeds the id counters past it. |
inspect() | Every panel with its live pixel rect, walked from the DOM. |
closeLeaf(id) | Close a panel. Closing the last one leaves a fresh blank panel. |
focusLeaf(id) | Target keyboard splits at a panel. No-op for a dead id. |
Tree model
panel-tree is importable on its own — pure, DOM-free, and unit-testable. Every
mutator returns a new tree and never mutates the one it was handed.
Don't memoize on node identity, though. A mutator rebuilds every node along the
path it walks, so a subtree it changed nothing in still comes back as a new
object — and a split that lost no children is rebuilt all the same. Compare by
id, which survives a mutation, rather than by reference.
| Export | Description |
|---|---|
panelLeaf · panelSplit · panelGroup | Builders for composing a tree. |
applySplit · applyClose · applyResize · applyDock · applyAddTab · applyActivateTab | The mutators the UI drives. |
extractLeaf · insertBeside | Pull a panel out and put it back — what a dock is made of. |
findLeaf · findLeafByView · firstLeafId | Queries. |
normalizeTree · normalizeGroup · wrapLayout · unwrapLayout | Migration and the persistence envelope. |
dockSideFromPoint · dockRect | Drag geometry: which edge a cursor is nearest, and the rect a drop would produce. |
reseedCounters | Lift the id counters past a restored tree so new nodes cannot collide. |