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.
<EditorPanels
top={<TitleBar left={<Label text="My Editor" layout="bar" />} />}
leftPanel={<LeftSections />}
rightPanel={<RightSections />}
center={<MyCanvas />}
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 <EditorPanels embedded />.
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 reachtopToolbars/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-leftand--stage-panel-cover-right: how much of it each panel currently covers, or0pxfor an inset panel or a collapsed one.
.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:
const [collapsed, setCollapsed] = createSignal(true);
<EditorPanels
leftPanel={<Settings />}
leftPanelMode="overlay"
leftCollapsedVariant="none"
leftCollapsed={collapsed}
onLeftToggle={setCollapsed}
topToolbars={
<Toolbar left={<IconToggle icon={CaretDoubleRight} title="Settings" value={() => !collapsed()} onChange={(open) => setCollapsed(!open)} />} />
}
center={<Canvas />}
/>
A tab starts at the middle of the edge. Set defaultLeftTabPosition / defaultRightTabPosition
to start it elsewhere, with offset in px:
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, 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,
defaultLeftWidthand 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.
<Section title="Transform" defaultOpen>
<FieldGroup>
<NumberField name="X" {...bind('x')} />
<NumberField name="Y" {...bind('y')} />
</FieldGroup>
</Section>
| 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:
<Section
title="Material"
headerActions={[
{ icon: Copy, title: 'Duplicate', onClick: dup },
{ icon: Lock, title: 'Lock', onClick: toggleLock, active: () => 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.
<Toolbar
left={<Label text="My Editor" layout="bar" />}
center={<ButtonGroupControl ... />}
right={<IconButton icon={FloppyDisk} title="Save" onClick={save} />}
/>
| 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).
<EditorPanels
top={<TitleBar left={<Label text="My Editor" layout="bar" />} />}
bottom={<TitleBar footer center={<Label text="Ready" layout="bar" />} />}
...
/>
| 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. |
Toolbaris transparent (the region paints it);TitleBarpaints itself. Reach forTitleBarwhen you want the prominent shade,Toolbarfor 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.
<TabMenu
initialTabs={['main.ts', 'utils.ts']}
onNewTab={() => 'untitled'}
onClose={(tab) => confirm(`Close ${tab.label}?`)}
/>
Controlled or uncontrolled:
| Prop | Type | Notes |
|---|---|---|
tabs? |
() => ReadonlyArray<TabMenuTab> |
Controlled tab list. |
activeId? |
() => string | undefined |
Controlled selection. |
initialTabs? |
ReadonlyArray<string | TabMenuTab> |
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.
<CanvasStage
onResize={({ width, height }) => renderer.setSize(width, height)}
topBars={<Toolbar left={<Label text="Viewport" layout="bar" />} />}
>
<CanvasStage.World mountRef={(el) => el.appendChild(renderer.domElement)} />
<CanvasStage.Hud>{/* DOM overlay UI, e.g. gizmos */}</CanvasStage.Hud>
<CanvasStage.Pointer>{/* pointer-capture layer */}</CanvasStage.Pointer>
</CanvasStage>
| 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 amountRefto 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.
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 for the full model.