# Layout
The structural primitives: the stage shell, panels, sections, bars, and the
canvas container.
---
## EditorPanels
The outer stage shell — a CSS grid of five rows (top bar / top toolbars / panel
band / bottom toolbars / bottom bar) with left/right panels in the band and your
content in the centre.
```tsx
} />}
leftPanel={}
rightPanel={}
center={}
persistKey="main"
/>
```
A root `EditorPanels` owns the window (`position: fixed; inset: 0`). Pass
`embedded` to make it fill a parent slot instead — that's the recursion point: a
stage's `center` can be another ``.
While a root `EditorPanels` is mounted, the page gets `overscroll-behavior: none`,
so a touch drag can't pull-to-refresh the app away. Pinch zoom and scrolling inside
panels keep working. A page with only embedded stages is untouched. To opt out, set
`overscroll-behavior` on `html` and `body` yourself; the library's rule has zero
specificity, so yours wins.
### Props
| Prop | Type | Notes |
|---|---|---|
| `center` | `JSX.Element` | **Required.** The world/content region. |
| `leftPanel?` / `rightPanel?` | `JSX.Element` | Side panel contents (sections, or a `DockZone`). |
| `top?` / `bottom?` | `JSX.Element` | Full-width single-bar regions (transparent — put a `TitleBar` here for the prominent shade, or a plain `Toolbar`). |
| `topToolbars?` / `bottomToolbars?` | `JSX.Element` | Full-width stacked toolbar rows (panel-bg). |
| `topSub?` / `bottomSub?` | `JSX.Element` | Canvas-aligned toolbar stacks (column 2 only — edges match the centre). |
| `embedded?` | `boolean` | Fill parent (relative) instead of owning the window (fixed). |
| `persistKey?` | `string` | Namespace for persisting panel collapse + width. Give each stage a distinct one. |
| `size?` | `ControlSize` | Size tier for the whole stage. |
| `leftSize?` / `rightSize?` | `ControlSize` | Per-panel size tier. |
| `wrapFocus?` | `boolean` | Loop Tab from last stop back to first (outermost stage only). |
| `resizable?` | `boolean` | Enable drag-to-resize panel edges. |
| `leftPanelMode?` / `rightPanelMode?` | `'inset' \| 'overlay'` | `'inset'` (default) reserves a column. `'overlay'` floats the panel over the center. See below. |
| `leftCollapsedVariant?` / `rightCollapsedVariant?` | `'strip' \| 'tab' \| 'none'` | What a collapsed panel leaves behind. Default `'strip'`. See below. |
| `defaultLeftTabPosition?` / `defaultRightTabPosition?` | `{ anchor: 'top' \| 'center' \| 'bottom', offset: number }` | Where a `'tab'` starts. See below. |
**Overlay panels.** An overlay panel floats over the center, inside its own stage, so opening,
collapsing or resizing it never changes the center's size. A WebGL canvas doesn't rebuild its
buffers on every animation frame, and it can still be dragged beside an open panel.
- Everything else works the same in both modes, and the mode can switch at runtime without
remounting the panel's contents or losing its collapse and width state.
- The panel covers `topSub` / `bottomSub`, since they're inside the center cell. It doesn't reach
`topToolbars` / `bottomToolbars`, so put anything that must stay visible there.
- Tapping outside doesn't close it.
- Floating panels and sections always draw above it.
- The center cell publishes `--stage-panel-cover-left` and `--stage-panel-cover-right`: how much
of it each panel currently covers, or `0px` for an inset panel or a collapsed one.
```css
.my-hud { left: calc(var(--stage-panel-cover-left) + 8px); }
```
Overlay suits a canvas-first app, where panels open now and then. A dense editor whose panels
stay open is usually better off inset, so that nothing sits over the canvas.
**Collapsed variants.** These choose what a collapsed panel leaves on screen, in either mode:
| Variant | On screen | Space it takes |
|---|---|---|
| `'strip'` | A full-height bar | A strip-width column. In overlay mode the column stays open or collapsed, so the center resizes only at mount, and the bar never covers it. |
| `'tab'` | A small button on the stage's edge | None. It sits over the center, which can reach the stage's edges. |
| `'none'` | Nothing | None. |
In overlay mode the center never resizes with any of them. In inset mode, `'tab'` and `'none'`
give the center the panel's whole column on collapse, so the center does resize.
With `'none'`, the app has to offer the way back. Control the panel and put the toggle somewhere
that stays visible, such as `topToolbars`, which a panel never covers:
```tsx
const [collapsed, setCollapsed] = createSignal(true);
}
leftPanelMode="overlay"
leftCollapsedVariant="none"
leftCollapsed={collapsed}
onLeftToggle={setCollapsed}
topToolbars={
!collapsed()} onChange={(open) => setCollapsed(!open)} />} />
}
center={}
/>
```
A tab starts at the middle of the edge. Set `defaultLeftTabPosition` / `defaultRightTabPosition`
to start it elsewhere, with `offset` in px:
```tsx
defaultLeftTabPosition={{ anchor: 'top', offset: 30 }} // 30px from the top
defaultRightTabPosition={{ anchor: 'bottom', offset: 50 }} // 50px from the bottom
defaultLeftTabPosition={{ anchor: 'center', offset: -10 }} // 10px above the middle
```
Inside a [`SectionLayoutProvider`](./dnd.md), the user can drag a tab along the edge whenever the
layout's panels aren't locked (`panelsLocked`). On release the tab anchors to whichever third of the band holds its center,
so it keeps its distance from that end when the window resizes. The position is saved under
`persistKey`, and it wins over the default until `layout.reset()`. Escape cancels a drag.
`--stage-panel-tab-width` and `--stage-panel-tab-height` set the tab's size,
and on touch screens its target is at least 44px wide whatever the visible width.
**Panel collapse** (per side, mirrored controlled/uncontrolled):
| Prop | Type |
|---|---|
| `leftCollapsed?` / `rightCollapsed?` | `() => boolean` |
| `onLeftToggle?` / `onRightToggle?` | `(next: boolean) => void` |
| `defaultLeftCollapsed?` / `defaultRightCollapsed?` | `boolean` |
| `leftToggleVariant?` / `rightToggleVariant?` | `'header' \| 'edge'` |
| `exclusiveWhenNarrow?` | `boolean` (default `true`) |
| `minCenterWidth?` | `number` (default `320`) |
**One panel at a time on a narrow stage.** When both panels at their expanded width, plus
`minCenterWidth` of center, don't fit the stage, opening one panel closes the other. If both
end up open anyway (on load, after a resize, after switching to a split view, or opened from
code), the right one closes. This keeps a phone from clipping a panel off the screen's edge.
- It applies in both panel modes, and only to a stage that has both a left and a right panel.
- Closing goes through the normal toggle path. A controlled panel receives
`onLeftToggle(true)` / `onRightToggle(true)`, so honor it the same way you honor a click.
- The widths are measured, so the size tier, `defaultLeftWidth` and a dragged width all count.
- Set `exclusiveWhenNarrow={false}` to let both panels stay open at any width.
**Panel width** (dragging needs `resizable`; persisted under `persistKey` if uncontrolled):
| Prop | Type |
|---|---|
| `leftWidth?` / `rightWidth?` | `() => number \| undefined` |
| `onLeftResize?` / `onRightResize?` | `(px: number) => void` |
| `defaultLeftWidth?` / `defaultRightWidth?` | `number` |
Uncontrolled panel collapse and width persist automatically when `persistKey` is
set. Width clamps to `[140px, 40% of the stage]`, and double-clicking the gutter resets it
to `defaultLeftWidth` / `defaultRightWidth`, or the tier default without one. Expanded width derives from the
size tier, so a `large` stage gets wider panels.
---
## Panel
A collapsible side column. You usually don't construct `Panel` directly —
`EditorPanels` builds the internal panels from `leftPanel`/`rightPanel`. Use
`Panel` directly only for a standalone collapsible column outside a stage.
Collapsing **shrinks the panel to a strip**, it does not unmount the contents. The
body stays in the document at its full width, hidden, clipped and `inert`, so its
state, scroll position and measurements survive, and its effects keep running. Don't
park something expensive in a panel expecting collapse to free it. An open select, color
or search overlay belonging to the body closes when the panel collapses, however the
collapse was triggered.
Two toggle placements:
- **`header`** (default) — a header strip across the top holds the collapse
toggle.
- **`edge`** — no header; a full-height toggle bar sits on the panel's inner edge,
reclaiming the header's vertical space.
| Prop | Type | Notes |
|---|---|---|
| `side` | `PanelSide` (`'left' \| 'right'`) | |
| `collapsed` | `() => boolean` | Controlled collapse state. |
| `onToggle` | `() => void` | |
| `children` | `JSX.Element` | |
| `size?` | `ControlSize` | |
| `toggleVariant?` | `'header' \| 'edge'` | |
| `resizable?` | `boolean` | |
| `onResize?` | `(px: number, final: boolean) => void` | |
| `onResetWidth?` | `() => void` | |
Overflow is tracked with an `IntersectionObserver` (scroll-hint affordances) — not
a scroll handler.
---
## Section
A collapsible, titled container — the unit you build property UI from. Holds
anything; naming it `Section` does **not** imply a registry. Its body adds padding and
no spacing, so wrap a stack of fields in `FieldGroup`. See
[Composing a section body](./guides.md#composing-a-section-body).
```tsx
```
| Prop | Type | Notes |
|---|---|---|
| `title` | `string` | |
| `children` | `JSX.Element` | |
| `class?` | `string` | Class on the section's root, for example to set `--stage-section-body-pad`. |
| `defaultOpen?` | `boolean` | Uncontrolled seed (default expanded). |
| `open?` | `() => boolean` | Controlled open state. |
| `onToggle?` | `(next: boolean) => void` | |
| `persistKey?` | `string` | Collapse-persist key (defaults to `title`). |
| `persist?` | `boolean` | `false` opts out of persistence. |
| `onClose?` | `() => void` | Dismissable mode (see below). |
| `headerActions?` | `SectionHeaderAction[]` | Icon buttons on the title bar. |
| `size?` | `ControlSize` | Sizes the section's *body* (header keeps the panel tier). |
| `dragHandle?` | `boolean` | Set by `DockZone` — makes the title a pure drag grip (press-drag reorders). The **chevron is the sole collapse control**; pressing the title does *not* collapse (it reads as a move handle, like the empty header bar). |
| `leadingAction?` | `SectionHeaderAction` | Replaces the chevron with a custom leading button (dragHandle mode). |
| `collapsible?` | `boolean` | `false` → always open: no chevron, and a header click does nothing (a `dragHandle` still drags). Default `true`. |
**Collapse keeps the body mounted** (height-collapsed + `inert`), not unmounted.
So a field keeps its state, stays in the DOM, and is deep-linkable — `focusField`
can expand and focus a collapsed section's field. (Trade-off: a collapsed
section's effects stay live; don't park something genuinely expensive there
expecting unmount to free it.)
**`headerActions`** put borderless, mouse-only icon buttons on the title bar:
```tsx
locked() },
]}
>
```
`SectionHeaderAction` = `{ icon, title, onClick, active?, disabled?, tone? }`
(`title` is required — it's the accessible name, rendered as `aria-label`; no OS tooltip).
**Dismissable mode** (`onClose` set): a static header (no chevron), body always
open, a × top-right. This is the shape the modal hosts.
---
## Toolbar
One bar with `left` / `center` / `right` anchor cells. Transparent layout only —
the hosting bar slot supplies the background. Stack several by listing them in a
slot.
```tsx
}
center={}
right={}
/>
```
| Prop | Type |
|---|---|
| `left?` / `center?` / `right?` | `JSX.Element` |
The center cell stays centered on the bar regardless of the left/right widths, which
limits `left` and `right` to half the bar each. When `center` renders nothing there's
nothing to keep centered, so they share the whole bar, and a `FieldBar grow` takes all the
room the other anchor leaves.
If the two don't fit, a fluid cell keeps half the bar and the other content overflows, so
truncate long text yourself.
---
## TitleBar
A **prominent** bar — the same `left` / `center` / `right` anchors as `Toolbar`, but
it **paints itself** with the recessed darker background (`--stage-color-bg`) and an
edge separator, marking it as the stage's title / primary bar. Use it where you want a
bar to stand out from the panel-bg toolbars; because it carries its own shade, that
prominence is a deliberate choice, not a side effect of position — drop a `TitleBar`
into any bar slot (`top`, `bottom`, a sub-bar).
```tsx
} />}
bottom={} />}
...
/>
```
| Prop | Type | Notes |
|---|---|---|
| `left?` / `center?` / `right?` | `JSX.Element` | Anchor cells (reuses `Toolbar`). |
| `footer?` | `boolean` | Separator on the **top** edge (a footer). Default header (bottom edge). |
| `class?` | `string` | Consumer style hook. |
> `Toolbar` is transparent (the region paints it); `TitleBar` paints itself. Reach for
> `TitleBar` when you want the prominent shade, `Toolbar` for a neutral bar.
---
## TabMenu
A horizontal document/object tab strip for a bar slot (e.g. `topSub`). Behaves
like a browser tab bar: arrow keys move + select, close (×) per tab, optional new
(+) button, a "modified" dot, and overflow scroll arrows.
```tsx
'untitled'}
onClose={(tab) => confirm(`Close ${tab.label}?`)}
/>
```
Controlled or uncontrolled:
| Prop | Type | Notes |
|---|---|---|
| `tabs?` | `() => ReadonlyArray` | Controlled tab list. |
| `activeId?` | `() => string \| undefined` | Controlled selection. |
| `initialTabs?` | `ReadonlyArray` | Uncontrolled seed (strings become tabs). |
| `defaultActiveId?` | `string` | Uncontrolled seed. |
| `onSelect?` | `(tab: TabMenuTab) => void` | |
| `onNewTab?` | `() => string \| false \| void` | Return a label, or `false` to veto. Renders the + when set. |
| `onClose?` | `(tab: TabMenuTab) => boolean \| void` | Return `false` to veto. |
| `newTabTitle?` | `string` | |
`TabMenuTab` = `{ id, label, modified? }`. A `modified` tab shows a dot (yielding
to the × on hover); deciding *when* a tab is modified is the consumer's job.
The menu fills its slot height — give it a full-height bar slot.
---
## CanvasStage
A multi-layer canvas container with a sized viewport. Vertical stack: top bars /
viewport / bottom bars. Bars *inset* (reflow) the viewport rather than overlaying
it, and the `ResizeObserver` watches the viewport, so `onResize` reports the real
world size.
```tsx
renderer.setSize(width, height)}
topBars={} />}
>
el.appendChild(renderer.domElement)} />
{/* DOM overlay UI, e.g. gizmos */}
{/* pointer-capture layer */}
```
| Prop | Type | Notes |
|---|---|---|
| `onResize?` | `(size: StageSize) => void` | `StageSize = { width, height }`. |
| `onMount?` | `(el: HTMLDivElement) => void` | The stage root element. |
| `topBars?` / `bottomBars?` | `JSX.Element` | Inset bar stacks. |
| `children?` | `JSX.Element` | The layers. |
The three layers are a compound API:
- **`CanvasStage.World`** — z-index 1; takes a `mountRef` to attach your renderer's
DOM (canvas).
- **`CanvasStage.Hud`** — z-index 20; overlay UI.
- **`CanvasStage.Pointer`** — z-index 30; pointer capture.
`STAGE_LAYERS` (`{ WORLD: 1, HUD: 20, POINTER: 30 }`) is exported if you need the
z-values directly. Drop a `CanvasStage` into an `EditorPanels` `center`.
---
## Size tiers
`EditorPanels`, `Panel`, and `Section` all take a `size?: ControlSize`
(`'small' | 'normal' | 'large'`). It scales font size and control height together,
and cascades — set it on a stage and everything inside scales; re-set it on a
section to override. See [Theming → Size tiers](./theming.md#size-tiers).
## Focus & keyboard
Tab order is the natural DOM order across the whole stage: top bars → left panel →
centre (including a nested stage, recursively) → right panel → bottom bars. The
library uses no positive `tabindex`. Set `wrapFocus` on the outermost stage to
loop. A collapsed section's fields are skipped (they're `inert`) but still in the
DOM. Escape inside a panel blurs the active field. See
[architecture §4](./architecture.md) for the full model.