Open Form

Layouts and chrome

Replace how fields, steps and screens look without touching behavior: theme components, the Stepper, Split and Focus layouts, and closed screens.

Built-in fields own behavior; their markup comes from replaceable components. So does the form's arrangement — the Layout — and its chrome: header, progress, buttons, success and closed screens. Replace any subset and the rest keep the default look. Validation, focus, steps and schema discovery keep working whatever you render.

import { Form, SplitLayout } from '@open-form-build/core';

<Form components={{ Layout: SplitLayout }}>…</Form>

Replaceable components

ComponentRendersKey props
FieldLabel, help and error around every field except Matrix, which Matrix and MatrixRow draw themselvesfield, children, as, hideLabel, hideDescription
TextInputText, Email, Url, Tel, NumberInput, DateInputfield, type, value, onChange(string), unit, placeholder, inputMode, autoComplete, maxLength, min / max / step
TextareaTextareafield, value, onChange, rows, maxLength, placeholder, autoComplete
SelectSelectfield, options, value, onChange, placeholder
PhoneInputTel with a country pickerfield, country, countries, onCountryChange(country), value, onChange(string), countryLabel, placeholder, autoComplete. The default renders the active Select and TextInput side by side
ChoiceGroupRadioGroup, CheckboxGroupfield, options, multiple, selected, onToggle(value, checked), variant
CheckboxCheckboxfield, checked, onChange(boolean), children, markRequired
RatingRatingfield, scores, value, onChange(number), lowLabel, highLabel
MatrixThe frame of a Matrix: question, column headings, rowslabel, description, required, columns, children (the rows)
MatrixRowOne row of a Matrixfield (the row's own), columns, value, onChange(string), markRequired
LayoutThe whole formThe regions below
HeaderTitle and descriptiontitle, description
ProgressStep indicatornav, label ("Step 2 of 5", localized)
NavBack / Next / Submitnav, submitLabel, labels
ButtonEvery button in Navvariant: 'primary' | 'secondary' plus button props
SuccessThank-you screentitle, children
NoticeClosed screensstate, eyebrow, title, message
LanguageSwitchLanguage picker on translated formsvalue, options, onChange, label

Each has a matching props type exported from @open-form-build/core: FieldUIProps, TextInputUIProps, LayoutUIProps, NoticeUIProps and so on.

A replacement control follows the custom field rules for field.controlProps and field.ref, and passes on the attributes it receives — inputMode, autoComplete, maxLength, placeholder — so mobile keyboards, autofill and limits keep working. Inside any replacement, useFormComponents() returns the components in effect, so a custom Nav can render the theme's Button. <RequiredMark /> is the built-in required asterisk, for a custom Field or Checkbox.

Override order

defaultComponents ← the theme's components ← <Form components>. The theme is the form's own look, passed as <Form theme>, so its components apply to that form only; <Form components> wins over them for one-off changes.

forms/survey/theme/index.ts
import { defineTheme, StepperLayout } from '@open-form-build/core';

export default defineTheme({ components: { Layout: StepperLayout } });
forms/survey/index.tsx
import theme from './theme/index.ts';

<Form theme={theme} components={{ ChoiceGroup: Chips }}>…</Form>

To extend instead of rewrite, render the default inside your own markup:

import { defaultComponents, type TextInputUIProps } from '@open-form-build/core';

function FramedInput(props: TextInputUIProps) {
  return (
    <div className="brand-frame">
      <defaultComponents.TextInput {...props} />
    </div>
  );
}

Built-in layouts

LayoutUse it for
(default)A centered card. General purpose.
StepperLayoutThree or more named steps — applications, onboarding. A step list beside the questions; visited steps are clickable. Give every <Page> a short title. With fewer than two visible steps, and on the success and closed screens, it shows the default card.
SplitLayoutBrand-forward forms — sales, waitlists, events. A panel with the title, progress and aside; questions on the other side. Stacks on small screens.
FocusLayoutConversational surveys. One screen per step, large type, Enter to continue. Put one question in each <Page> and leave page titles off. Doesn't show aside.

When the form has a title or description, FocusLayout opens with a welcome screen built from them and a Start button, so write them as an invitation. Enter advances from a Textarea too; Shift + Enter adds a line. On touch screens, Return in a Textarea adds a line and Cmd/Ctrl + Enter advances. To show the title above the first question instead of on its own screen:

<Form components={{ Layout: (props) => <FocusLayout {...props} cover={false} /> }}>…</Form>

Layout regions

A Layout receives ready-made regions (LayoutUIProps) and arranges them:

Prop

Type

import type { LayoutUIProps } from '@open-form-build/core';

function Centered({ state, header, progress, content, alert, actions }: LayoutUIProps) {
  return (
    <div className="my-shell" data-state={state}>
      {state === 'form' && header}
      {progress}
      {content}
      {alert}
      {actions}
    </div>
  );
}

A custom Nav or Button must keep type="submit" on the primary button so Enter still advances.

On a translated form, the language picker (.of-lang) sits 12px inside the top-right corner of .of-root and is 32px tall. The built-in layouts start below it. A custom Layout, or CSS that changes .of-root's padding, has to keep that corner clear at every width, phones included. Reserve the room, or move the picker into space your layout leaves free:

.of-root[data-of-theme="acme"]:has(> .of-lang) {
  padding-top: clamp(56px, 6vw, 64px);
}

Closed screens — Notice

Respondents see Notice instead of the questions when the form isn't taking responses. It receives state, eyebrow (the form's title), title and message, already translated.

stateWhenmessage
'closed'closed: true, after closesAt, or closed in StudioclosedMessage, or a built-in sentence
'not-open'Before opensAtA built-in sentence with the opening date
'full'maxResponses reachedfullMessage, or a built-in sentence
'duplicate'uniqueBy: 'device' and this browser already respondedduplicateMessage, or a built-in sentence
'not-found'A <FormHost> asked for a form the server doesn't have, or one that's unpublished; title is "Form not found"A built-in sentence: the link may be mistyped, or the form is no longer available
forms/meetup/index.tsx
import { useContext } from 'react';
import { Form, type NoticeUIProps, ScreenHeadingContext, T } from '@open-form-build/core';

function MeetupClosed({ state, eyebrow, title, message }: NoticeUIProps) {
  const Heading = useContext(ScreenHeadingContext);
  return (
    <div className="meetup-closed">
      {eyebrow && Heading === 'h1' && <p className="of-eyebrow">{eyebrow}</p>}
      <Heading className="of-title">{state === 'full' ? <T>All 100 seats are taken</T> : title}</Heading>
      <p className="of-description">{message}</p>
    </div>
  );
}

<Form components={{ Notice: MeetupClosed }}>…</Form>

Branch on state for what differs and fall back to title and message, so a state you didn't plan for still reads right. Keep policy messages in config.submissions, where Studio and the server use them too. Render one <h1>: ScreenHeadingContext is 'h2' inside layouts that keep the header, and its <h1>, on the closed and thank-you screens, like SplitLayout, so use it for the title and skip eyebrow there. A custom Layout that does the same wraps content in <ScreenHeadingContext.Provider value="h2">. Studio's Preview shows every closed screen the form can reach.

On this page