Open Form

Layouts and chrome

Replace how fields, steps and screens look without touching behavior.

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 fieldfield, 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
ChoiceGroupRadioGroup, CheckboxGroupfield, options, multiple, selected, onToggle(value, checked), variant
CheckboxCheckboxfield, checked, onChange(boolean), children, markRequired
RatingRatingfield, scores, value, onChange(number), lowLabel, highLabel
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 copy, 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'; // the path `theme copy` printed

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

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; title is "Form not found"closedMessage, or a built-in sentence
forms/meetup/index.tsx
import { Form, type NoticeUIProps, T } from '@open-form-build/core';

function MeetupClosed({ state, eyebrow, title, message }: NoticeUIProps) {
  return (
    <div className="meetup-closed">
      {eyebrow && <p className="of-eyebrow">{eyebrow}</p>}
      <h1 className="of-title">{state === 'full' ? <T>All 100 seats are taken</T> : title}</h1>
      <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>; layouts that keep the header on the closed screen, like SplitLayout, already show the title as the <h1>, so use <h2> and skip eyebrow there. Studio's Preview shows every closed screen the form can reach.

On this page