@workspace/form-renderer package. Each layout is a self-contained directory with two files — a manifest and a shell component — plus a single line in the registry.
File structure
Step 1 — Create the manifest
manifest.ts declares the layout’s identity, its SVG thumbnail (shown in the Design panel picker), and the config fields it accepts.
LayoutManifest reference
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier. Stored in theme.layout.id. |
name | string | Display name in the layout picker. |
thumbnail | string | Inline SVG string. Use a 120×80 viewBox. |
configSchema | LayoutConfigField[] | Declares what options this layout exposes. |
defaultConfig | Record<string, unknown> | Default values keyed by field.key. |
LayoutConfigField reference
| Field | Type | Description |
|---|---|---|
key | string | Key used in theme.layout.config. |
label | string | Human-readable label in the Design panel. |
type | "boolean" | "number" | "text" | Renders as a toggle, range slider, or text input respectively. |
min | number? | Minimum value for number fields. |
max | number? | Maximum value for number fields. |
defaultValue | unknown? | Per-field fallback (rarely needed; defaultConfig is preferred). |
Step 2 — Create the shell component
layout.tsx is a React component that receives LayoutShellProps and renders the page structure. It is responsible for placing slots.formCard wherever the form card should appear.
LayoutShellProps reference
| Prop | Type | Description |
|---|---|---|
theme | FormTheme | The full active theme — colors, typography, background, branding, shape. |
config | Record<string, unknown> | Runtime values from theme.layout.config. Always read with a default fallback. |
slots.formCard | React.ReactNode | The animated question card, pre-built by the caller. Place this wherever the question should appear. |
slots.questionCounter | React.ReactNode? | Optional “Question N of M” counter. Render it wherever you like. |
progressPct | number | Current progress 0–100. Use this to drive your own progress bar. |
runner | FormRunnerState? | Headless runner state — only available in the live landing app, not in studio preview. |
isPreview | boolean? | true when rendered inside the studio canvas. |
Always read
config values defensively with fallbacks. If a respondent has an old theme saved without your new config keys, those keys will be missing.Step 3 — Using the runner (advanced)
Therunner prop is only available in the live landing app — not in the studio canvas. It gives you access to the current question node and the respondent’s traversal state. The Split layout uses this to show the current question title in its sidebar.
runner is undefined (studio preview), the sidebar simply renders without question content. This is the expected behaviour — guard all runner access with optional chaining.
Step 4 — Register the layout
Add a single import and one entry toregistry.ts:
- The Design panel thumbnail picker (both apps)
- The
getAllLayouts()export (for any custom UI that lists layouts) - The
resolveLayout("my-layout")lookup (used at render time)
Mobile considerations
The studio canvas renders your layout at full width. On mobile the layout is shown inside a scaled device frame. A few conventions to follow:- Use
md:Tailwind breakpoints for desktop-only elements (as the Split layout’s sidebar does) - Provide a fallback mobile treatment — a slim fixed header is a good pattern
- Avoid fixed widths in
pxon the outermost wrapper; use%orvwso device-frame scaling works correctly
Complete example
The built-in Split layout is a good reference for a more complex layout using the runner:Next steps
Layouts overview
See the three built-in layouts and their config options.
Content-type plugins
Build custom question types with their own rendering logic.