> ## Documentation Index
> Fetch the complete documentation index at: https://docs.feedal.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Custom Layout

> Developer guide for creating and registering a new layout in the form-renderer package.

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.

```ts theme={null}
// 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

| 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.

```tsx theme={null}
// 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

| 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.                                                       |

<Note>
  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.

  ```ts theme={null}
  // ✅ Good
  const myValue = typeof config.myKey === "number" ? config.myKey : 42;

  // ❌ Risky
  const myValue = config.myKey as number;
  ```
</Note>

***

## 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.

```tsx theme={null}
// 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`:

```ts theme={null}
// 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

<CardGroup cols={2}>
  <Card title="Layouts overview" icon="layout" href="/theming/layouts">
    See the three built-in layouts and their config options.
  </Card>

  <Card title="Content-type plugins" icon="plug" href="/developer/content-type-plugins">
    Build custom question types with their own rendering logic.
  </Card>
</CardGroup>
