DreamLake

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:

LayerWhat it is
panel-treeThe pure tree — nodes, migration, mutators. No React, no DOM.
panel-boxPanel geometry as CSS math, published as --panel-x / --panel-w.
PanelLayoutThe 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.

loading…
Give it a height

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.

loading…

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.

tsx
<PanelLayout
  ref={layout}
  tabbed
  initial={() => panelGroup([panelLeaf({ view: 'editor', title: 'Draft' }), panelLeaf({ view: 'preview', title: 'Preview' })])}
  renderTab={(leaf) => <span>{leaf.title}</span>}
  renderHeader={(leaf) => <ViewActions viewId={leaf.id} />}
  renderBody={(leaf) => <ViewBody viewId={leaf.id} kind={leaf.view} />}
  onFocusedLeafChange={(leaf) => setActiveView(leaf.id)}
  onRequestClose={(id) => {
    if (canClose(id)) layout.current?.closeLeaf(id)
  }}
/>

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.

loading…

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.

tsx
<PanelLayout initial={initial} closable={(leaf) => leaf.view !== 'subject'} primaryView="subject" />

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.

loading…

Driving it from code

The ref handle mutates the same tree the user does, and onChange reports every mutation from either source.

loading…
onChange → …

Persisting a layout

Save on onChange, restore in initial. usePersistedPanelLayout is both halves, with the parts that are easy to get wrong already handled.

tsx
const persisted = usePersistedPanelLayout({
  key: 'my-app:layout',
  fallback: () => panelSplit('row', [panelLeaf({ view: 'chat' }), panelLeaf({ view: 'files' })]),
  validate: (root) => hasView(root, 'chat'), // reject a saved tree missing the primary panel
})

<PanelLayout initial={persisted.initial} onChange={persisted.onChange} />
loading…
split / drag / resize, then reload the page — it comes back
If you hand-roll it: unwrap before you validate

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.

OptionTypeDefaultDescription
keystring—Storage key. Namespace it per user or workspace.
fallback() => PanelNode—Tree to use when nothing usable is saved.
storageStoragelocalStorageAny 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.
debounceMsnumber150Write 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.

Reset is not the same door as restore

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.

tsx
const scopeOf = (leaf) => (GLOBAL_VIEWS.has(leaf.view) ? 'global' : 'document')

usePersistedPanelLayout({
  key: `doc:${docId}:layout`,
  fallback: freshDocLayout,
  transform: (root) => scopeSubtree(root, 'document', scopeOf),
})

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.

tsx
const plan = resolveViewSplit({
  root: panelRef.current?.getRoot() ?? null,
  view: 'diff',
  anchorView: 'editor', // open alongside the primary panel
  fallbackLeafId: firstLeafId,
  prefs, // remembered { dir, before, px } per view
  widthOf: (id) => measure(id),
})
if (!('skip' in plan)) {
  panelRef.current?.splitWith(plan.targetId, plan.dir, 'diff', 'Diff', plan.before, plan.fraction)
}

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

WhereKeys
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 flightEscape 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.

Docking is pointer-only

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

PropTypeDefaultDescription
initial() => PanelNode3-panel demoBuilds the initial tree. Called once, on mount.
initialSinglebooleanfalseWithout initial: true starts with one panel.
rootPanelNode—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) => ReactNodeplaceholderRenders a panel's content. Dispatch on leaf.view.
renderHeader(leaf: LeafNode) => ReactNodedot + titleReplaces the header's centre content.
isTabbable(view?: string) => booleanevery viewReturn false to make a view a singleton that can never be tab-merged.
closable(leaf: LeafNode) => booleanevery panelReturn false to withhold a panel's close affordances and refuse those closes. The handle's closeLeaf is not gated by it.
primaryViewstring—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 | undefinedoverflow-hiddenReplaces the overflow policy on the custom-header wrapper.
rootBoxPanelBox--panel-area-x/-wThe box the tree's root occupies, as CSS math.
contentColumnHeaderClassstring—Class for the header of a panel that has both a custom header and a contentMax — the hook for a viewport-centered content column.
palettereadonly string[]8 tintsTints for placeholder panels and the drag surrogate.
splitShortcutfalse | { key?: string }{ key: 'd' }⌘/Ctrl + key splits the focused panel (⇧ = vertical). false removes the binding.
showToolbarbooleanfalseShow the built-in toolbar (title, hints, Reset).
toolbarReactNode—Replace the toolbar's contents. Implies showToolbar.
resetTo() => PanelNodeinitialWhat the toolbar's Reset builds. Point it at a fresh tree whenever initial restores one.
classNamestring—Extra classes on the root element.

Handle

ref exposes the imperative API.

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

ExportDescription
panelLeaf · panelSplit · panelGroupBuilders for composing a tree.
applySplit · applyClose · applyResize · applyDock · applyAddTab · applyActivateTabThe mutators the UI drives.
extractLeaf · insertBesidePull a panel out and put it back — what a dock is made of.
findLeaf · findLeafByView · firstLeafIdQueries.
normalizeTree · normalizeGroup · wrapLayout · unwrapLayoutMigration and the persistence envelope.
dockSideFromPoint · dockRectDrag geometry: which edge a cursor is nearest, and the rect a drop would produce.
reseedCountersLift the id counters past a restored tree so new nodes cannot collide.