Skip to main content
Layouts are registered in the @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

packages/form-renderer/src/v2/layouts/
├── registry.ts              ← add your layout here (one import + one entry)
├── default/
│   ├── manifest.ts
│   └── layout.tsx
├── centered-brand/
│   ├── manifest.ts
│   └── layout.tsx
├── split/
│   ├── manifest.ts
│   └── layout.tsx
└── my-layout/               ← create this directory
    ├── manifest.ts
    └── layout.tsx

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.
// packages/form-renderer/src/v2/layouts/my-layout/manifest.ts
import type { LayoutManifest } from "../../types";

export const manifest: LayoutManifest = {
  id: "my-layout",           // must be unique across all registered layouts
  name: "My Layout",         // shown in the design panel picker

  // Inline SVG thumbnail — 120×80 viewBox is the standard size
  thumbnail: `<svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
    <rect width="120" height="80" rx="4" fill="#f8fafc"/>
    <!-- draw a rough wireframe of your layout here -->
    <rect x="10" y="20" width="100" height="40" rx="5" fill="#e2e8f0"/>
  </svg>`,

  // Config fields shown in Design → Customise → Layout
  configSchema: [
    { key: "showProgressBar", label: "Show progress bar", type: "boolean" },
    { key: "showQuestionCount", label: "Show question count", type: "boolean" },
    { key: "accentWidth", label: "Accent bar width (px)", type: "number", min: 2, max: 12 },
  ],

  // Default values for each config key
  defaultConfig: {
    showProgressBar: true,
    showQuestionCount: false,
    accentWidth: 4,
  },
};

LayoutManifest reference

FieldTypeDescription
idstringUnique identifier. Stored in theme.layout.id.
namestringDisplay name in the layout picker.
thumbnailstringInline SVG string. Use a 120×80 viewBox.
configSchemaLayoutConfigField[]Declares what options this layout exposes.
defaultConfigRecord<string, unknown>Default values keyed by field.key.

LayoutConfigField reference

FieldTypeDescription
keystringKey used in theme.layout.config.
labelstringHuman-readable label in the Design panel.
type"boolean" | "number" | "text"Renders as a toggle, range slider, or text input respectively.
minnumber?Minimum value for number fields.
maxnumber?Maximum value for number fields.
defaultValueunknown?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.
// packages/form-renderer/src/v2/layouts/my-layout/layout.tsx
import { getBackgroundStyles } from "../../theme";
import { BrandingDisplay } from "../../BrandingDisplay";
import type { LayoutShellProps } from "../../types";

export function MyLayout({ theme, slots, progressPct, config, runner }: LayoutShellProps) {
  const showProgressBar   = config.showProgressBar   !== false;
  const showQuestionCount = config.showQuestionCount  === true;
  const accentWidth       = typeof config.accentWidth === "number" ? config.accentWidth : 4;

  return (
    <div style={{ ...getBackgroundStyles(theme), minHeight: "100vh", display: "flex", flexDirection: "column" }}>

      {/* Your custom top bar / header */}
      <div
        style={{
          borderLeft: `${accentWidth}px solid ${theme.colors.primary}`,
          padding: "1rem 1.5rem",
          backgroundColor: "var(--fr-surface)",
        }}
      >
        {theme.branding && <BrandingDisplay branding={theme.branding} />}
      </div>

      {/* Optional progress bar */}
      {showProgressBar && (
        <div className="w-full h-[3px]" style={{ backgroundColor: `${theme.colors.primary}20` }}>
          <div
            className="h-full"
            style={{
              width: `${progressPct}%`,
              backgroundColor: theme.colors.primary,
              transition: "width 0.5s ease",
            }}
          />
        </div>
      )}

      {/* Form card — center it however your layout demands */}
      <div className="flex-1 flex items-center justify-center px-5 py-16">
        <div className="w-full max-w-xl">
          {slots.formCard}
        </div>
      </div>

      {/* Optional question counter */}
      {showQuestionCount && slots.questionCounter}
    </div>
  );
}

LayoutShellProps reference

PropTypeDescription
themeFormThemeThe full active theme — colors, typography, background, branding, shape.
configRecord<string, unknown>Runtime values from theme.layout.config. Always read with a default fallback.
slots.formCardReact.ReactNodeThe animated question card, pre-built by the caller. Place this wherever the question should appear.
slots.questionCounterReact.ReactNode?Optional “Question N of M” counter. Render it wherever you like.
progressPctnumberCurrent progress 0–100. Use this to drive your own progress bar.
runnerFormRunnerState?Headless runner state — only available in the live landing app, not in studio preview.
isPreviewboolean?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.
// ✅ Good
const myValue = typeof config.myKey === "number" ? config.myKey : 42;

// ❌ Risky
const myValue = config.myKey as number;

Step 3 — Using the runner (advanced)

The runner 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.
// Show the current question title somewhere in your layout
const currentTitle = runner?.currentNode?.data.title ?? "";
const currentDescription = runner?.currentNode?.data.description ?? "";

{currentTitle && (
  <div>
    <h2>{currentTitle}</h2>
    {currentDescription && <p>{currentDescription}</p>}
  </div>
)}
If 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 to registry.ts:
// packages/form-renderer/src/v2/layouts/registry.ts

import { manifest as myLayoutManifest } from "./my-layout/manifest";
import { MyLayout } from "./my-layout/layout";

const LAYOUT_REGISTRY: LayoutEntry[] = [
  { manifest: defaultManifest,       Shell: DefaultLayout       },
  { manifest: centeredBrandManifest, Shell: CenteredBrandLayout },
  { manifest: splitManifest,         Shell: SplitLayout         },
  { manifest: myLayoutManifest,      Shell: MyLayout            }, // ← add this
];
That is everything. No other files need changing. The layout will immediately appear in:
  • 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 px on the outermost wrapper; use % or vw so device-frame scaling works correctly

Complete example

The built-in Split layout is a good reference for a more complex layout using the runner:
packages/form-renderer/src/v2/layouts/split/
├── manifest.ts    — 5 config fields including number (sidebarWidth) and text (sidebarImageUrl)
└── layout.tsx     — runner integration, mobile collapse, decorative image, gradient overlay

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.