Multi-step Form
A fixed-height form that derives its own pages by measuring its fields.
Most steppers ask you to declare the steps. This one measures and works them out, because the thing a layout actually depends on — how tall a field renders at this width — is not knowable when the markup is written. A short input and a four-row textarea are not the same height, and the answer changes with the container.
The contract is that the outer height never changes. Pages are split to fit that height, not the other way around.
Default
Six fields of unequal height. Nothing here declares a step.
Inputs
Height drives the split
The same six fields at three heights. The frame is whatever you asked for and never moves; the number of pages is whatever fits.
height takes a number (pixels) or any CSS length. The default is
min(28rem, 70vh) — capped against the viewport so a short window can never
push the footer, and therefore the only way to advance, off-screen. Only
mode="single" accepts height="auto"; see below.
Fixed at 360px
Page-sizing modes
mode decides how many fields go on a page.
fill(default) packs each page with as many fields as fit. Fewest pages, but ragged — a tall field that follows three short ones lands on a page of its own, so the reader gets three fields, then one, then four.uniformgives every page the same number of fields, chosen so that even the tallest of them fits, then spreads the fields evenly across the resulting pages. Seven fields at three-per-page come out3,2,2, not3,3,1.singleputs one field on every page. It measures nothing, so the page count is the field count.
Worked example: if a labelled input tops out at 150px and the content area is
450px tall, uniform puts three per page — and keeps three per page no matter
which fields land where.
The trade-off is real and worth knowing. uniform is sized by the worst
case, so a single outlier — one tall textarea among ten inputs — collapses
every page down to what fits that outlier. fill never wastes space but
never looks even. Reach for uniform when the fields are of similar height,
which is the usual shape of a labelled form.
single is for one question at a time. Because it does not split by height, it
is the one mode that can take height="auto": the frame then follows the
visible field. Pair it with minHeight and maxHeight so a short field does
not read as a strip and a long one scrolls inside the frame instead of pushing
the actions off-screen.
mode="fill"
Inputs
mode="uniform"
Inputs
Gating a page
canAdvance receives the page being left and the keys of the fields on it.
Return false to stay put, or a promise to gate on async validation — the
primary action shows a loading state until it settles.
Validate the fields in fieldKeys, not the page number. Which fields share
a page is derived from measurement and changes with the container: fields A and
B may be pages 0 and 1 when narrow and both page 0 when wide, so a validator
that maps step 0 to A would pass the page while B went unchecked.
Name each field with fieldKey. fieldKeys reports an item's fieldKey.
Without one it falls back to the React key as Children.toArray rewrites it, so
key="brand" arrives as .$brand — a check against 'brand' matches nothing
and lets the page through.
Validate only that page's fields — checking the whole form would report errors on fields the person has not reached yet. Back is never gated, because a half-filled page someone is leaving is not an error.
Required on page one
Custom chrome
useMultiStepForm() exposes the state and actions, so a surface can replace the
footer entirely instead of prop-drilling next and back. It returns step,
stepCount, isFirstStep, isLastStep, isBusy, next, back, goTo,
submit, visibleFieldKeys, gap, and mode. visibleFieldKeys names the
fields on the visible page and is empty until the first measurement.
Custom footer
A form that fits
No counter, no Back, no page chrome. A form that did not need to split should not advertise that it could have — so the stepping UI simply is not there.
Fits in one page
Anatomy
| Slot | Purpose |
|---|---|
Root | Owns height, page state, and the advance gate. |
Header / Footer | Fixed regions. Subtracted from the usable content height, never paged. |
Content | The paged region. Each direct child is one indivisible block. |
Item | Groups a label with its control so a split cannot fall between them. |
Actions / Back / Next | Default controls. Actions accepts children to replace them. |
Counter / Progress | The 2 of 5 readout and a hairline fill. Both hide on a single-page form. |
Root props
| Prop | Type | Default |
|---|---|---|
height | number | string | min(28rem, 70vh) |
minHeight / maxHeight | number | string | — |
gap | number | 8 |
mode | 'fill' | 'uniform' | 'single' | 'fill' |
step / defaultStep | number | 0 |
onStepChange | (step: number) => void | — |
onSubmit | () => void | Promise<void> | — |
canAdvance | (step, fieldKeys) => boolean | Promise<boolean> | — |
presentation | 'form' | 'interactive' | 'form' |
primaryShortcut | (fieldKeys, isLastStep) => 'enter' | 'mod-enter' | — |
primaryDisabled | boolean | false |
className | string | — |
minHeight and maxHeight bound a frame sized by height="auto". A promise from
onSubmit or canAdvance keeps the form busy until it settles. presentation="interactive"
drops the bordered frame for the composer's slot layout and motion. primaryShortcut
lets Enter, or Cmd/Ctrl+Enter, run the primary action for the visible fields;
primaryDisabled turns that shortcut off.
Slot props
| Slot | Prop | Type | Default |
|---|---|---|---|
Item | fieldKey | string | — |
Next | submitLabel | ReactNode | 'Submit' |
Next | hint | ReactNode | — |
Next | disabled | boolean | false |
Back | showOnFirstStep | boolean | false |
Counter | format | (step: number, stepCount: number) => string | — |
Counter | showWhenSingleStep | boolean | false |
Content | sizing | 'tallest' | 'active' | 'tallest', or 'active' when interactive |
Next reads "Next" until the last page, where it reads submitLabel. hint adds a
trailing adornment such as a Kbd. Counter's format receives the one-based page.
sizing only applies to a frame sized by height="auto": tallest reserves room for
the tallest field, active follows the visible one.
Notes
- Every field stays mounted, on or off the visible page. Paging back never
loses what was typed, and form-library registrations survive. Off-page fields
are moved out of flow and made
inert. - Give dynamic children explicit keys. Wrappers key on the child's own key,
and
Children.toArraygenerates a positional one (.0,.1) for an unkeyed child — an index wearing a different name. A list whose fields can be inserted, removed, or reordered therefore needs real keys from you. A static list needs nothing. - A field taller than the page gets its own page, and that page scrolls. Dropping it would hide a field and submit without it.
- Default button labels are English literals. Pass translated nodes as
children of
BackandNext, and assubmitLabel. TheCounterreadout is translated; useformatto replace it.