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.
  • uniform gives 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 out 3,2,2, not 3,3,1.
  • single puts 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

Page 1 of 1

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

SlotPurpose
RootOwns height, page state, and the advance gate.
Header / FooterFixed regions. Subtracted from the usable content height, never paged.
ContentThe paged region. Each direct child is one indivisible block.
ItemGroups a label with its control so a split cannot fall between them.
Actions / Back / NextDefault controls. Actions accepts children to replace them.
Counter / ProgressThe 2 of 5 readout and a hairline fill. Both hide on a single-page form.

Root props

PropTypeDefault
heightnumber | stringmin(28rem, 70vh)
minHeight / maxHeightnumber | string—
gapnumber8
mode'fill' | 'uniform' | 'single''fill'
step / defaultStepnumber0
onStepChange(step: number) => void—
onSubmit() => void | Promise<void>—
canAdvance(step, fieldKeys) => boolean | Promise<boolean>—
presentation'form' | 'interactive''form'
primaryShortcut(fieldKeys, isLastStep) => 'enter' | 'mod-enter'—
primaryDisabledbooleanfalse
classNamestring—

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

SlotPropTypeDefault
ItemfieldKeystring—
NextsubmitLabelReactNode'Submit'
NexthintReactNode—
Nextdisabledbooleanfalse
BackshowOnFirstStepbooleanfalse
Counterformat(step: number, stepCount: number) => string—
CountershowWhenSingleStepbooleanfalse
Contentsizing'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.toArray generates 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 Back and Next, and as submitLabel. The Counter readout is translated; use format to replace it.