Concepts #
The mental model behind the whole library. Read this once and the rest of the docs slot into place.
The shape of an editor #
solid-stage assumes a particular UI: a world in the middle (a canvas, a preview, a node graph) flanked by dense property panels. It gives you the chrome around that world — panels, sections, bars, form controls, overlays — and stays out of the world itself.
┌────────────────────────────────────────────┐
│ top bar / toolbars │
├──────────┬───────────────────────┬──────────┤
│ │ │ │
│ left │ center │ right │
│ panel │ (your world) │ panel │
│ │ │ │
├──────────┴───────────────────────┴──────────┤
│ bottom toolbars / bar │
└────────────────────────────────────────────┘
That whole frame is EditorPanels. Its center is just JSX.Element, so it
can host anything — the chrome is deliberately world-agnostic.
The pieces, top to bottom #
- Stage — one
EditorPanelsinstance. A root stage owns the window; anembeddedstage fills a parent slot (stages can nest — a stage'scentercan be another stage). - Panel — a collapsible side column (
leftPanel/rightPanel). Collapsing shrinks it to a strip rather than unmounting it. (Layout) - Section — a collapsible titled container inside a panel; the unit you build property UI from. (Layout)
- Field / Control — the form widgets inside a section (see below).
- Bar — a horizontal strip (top/bottom of the stage, or inside a canvas).
Compose one with
Toolbar(left/center/rightcells) or drop in aTabMenu. (Layout) - Overlays — app-level singletons that float above everything: tooltip, modal, toast. (Overlays)
Control vs Field — the key distinction #
Every form widget comes in two tiers, and knowing which you want saves a lot of confusion:
- A
*Controlis the bare widget — just the input. It takes avalueand anonChange, its widget-specific props, and anaria-label. No caption, no layout row. Use it when you're placing a widget yourself (e.g. a slider in a toolbar). - A
*Fieldis the captioned form row that wraps the control: it adds the label, the optional description, info bubble, and validation. This is what you put in aSection. It composes the matching control internally.
// Bare control — you own the layout:
<SliderControl value={zoom} onChange={setZoom} min={0} max={100} aria-label="Zoom" />
// Field — captioned row for a panel:
<SliderField name="Zoom" value={zoom} onChange={setZoom} min={0} max={100} />
Both are public. Reach for the Field in a panel; reach for the Control when you need just the widget somewhere custom. Full list in Controls & fields.
Accessors, not values (the Solid convention) #
solid-stage is reactive-first. A widget's value is an accessor — a function you pass, not a value. Reads stay fine-grained and the widget tracks changes without re-creating.
const [zoom, setZoom] = createSignal(50);
<SliderField name="Zoom" value={zoom} onChange={setZoom} min={0} max={100} />
// ^^^^ the getter itself, not zoom()
The same goes for readonly?: () => boolean, disabled?: () => boolean, etc. —
they're accessors so the widget reacts when they flip. onChange is a plain
callback (next) => void. If you use createEditorStore, bind(...) produces a
{ value, onChange } pair for you so you never write these by hand.
Controlled and uncontrolled, everywhere #
Anything stateful (a panel's collapse, a section's collapse, a tab selection) works two ways, mirrored consistently:
- Uncontrolled — omit the state props; the component manages its own state and
(where it makes sense) persists it to
localStorage. ASectionremembers its collapse out of the box. - Controlled — pass the state accessor + a change handler (
open+onToggle,collapsed+onToggle,tabs+onSelect, …); you own the state and the component never caches.
The canonical pattern is isOpen = () => props.open?.() ?? internalOpen().
Theming is CSS variables on .stage-root #
There are no inline-style props for colours or sizes. Every dimension and colour
is a CSS custom property declared on .stage-root; you override by setting that
variable on any container. Defaults use :where() (zero specificity), so your
overrides win without !important.
.my-editor { --stage-color-accent: #6c9; --stage-border-radius: 4px; }
Three size tiers (small / normal / large) scale font + control height
together; set one with the size prop or a .stage-size-* class and everything
inside scales. See Theming.
The dock model (movable sections) #
By default you author sections as static JSX children of a panel. If you want
users to drag sections to reorder them, float them over the canvas, or move
them between panels, you opt into the dock model: sections become data in a
createSectionLayout controller, panels render DockZones from it, and a single
StageFloatHost handles floating. This is entirely optional — static authoring
stays the default. See Drag, dock & float.
Overlays are singletons + a host #
The tooltip, modal, and toast are global singletons you call imperatively
(stageModal.open(...), stageToast.push(...)). The modal and toast each need a
one-time host component (<StageModalHost />, <StageToastHost />) mounted once
inside your .stage-root; the tooltip mounts itself. See Overlays.
What the library does not do #
- No SSR — browser-only by design.
- No section registry —
EditorPanelsandSectiontake children, not id lists. (The dock model is the opt-in exception, and even it is child-rendered.) - No world rendering —
CanvasStagegives you sized layers; what you draw in them is yours. - No global state framework —
createEditorStoreis a convenience store, not a required architecture.