Wizard
A recipe, not a package namespace. A wizard splits one long form into steps: a Steps list says where the person is, each step is its own Form with its own rules, and the last step sends everything. Which steps there are and what each asks for are up to your product. There is no Wizard.* export.
On this page
Go through the steps, or use ben@example.com and the workspace name Todae, which the stand-in server knows, to see errors from the last step come back to the steps that ask for them.
- In progress: Account
- Not started: Workspace
- Not started: Review
A form per step
Give each step a Form.Root, so the browser checks only that step's controls when the person presses Next, and a step with a problem keeps focus on its first invalid control as any form does. Keep the answers in one state for the whole wizard, so Back keeps what the person typed and the last step can show it all.
Keep every step's form mounted and hide the ones that aren't showing with hidden. A hidden form is out of the tab order and the accessibility tree, and it still holds its controls. A form that unmounts forgets that it is waiting on a reply, which the last step needs, as the section on its errors shows. Don't share one Form.Root across the steps: after one submit, a form shows the problems of every control it holds, so the next step would open with all its fields marked invalid.
import { Button, ErrorMessage, Field, Form, Label, Stack, Text } from '@tounsoo/todae';
<Form.Root
ref={(element) => {
forms.current[0] = element;
}}
hidden={step !== 0}
aria-labelledby={headingIds[0]}
errors={0 <= reached ? pick(reply, STEPS[0].fields) : {}}
onSubmit={next(0)}
>
<Stack.Root gap="normal">
<Text.Root
as="h2"
id={headingIds[0]}
ref={(element) => {
headings.current[0] = element;
}}
tabIndex={-1}
>
<Text.Root as="span" variant="label" style={{ display: 'block' }}>
Step 1 of 3
</Text.Root>
Account
</Text.Root>
<Field.Group>
<Label.Root htmlFor={emailId}>Work email</Label.Root>
<Field.Root id={emailId} name="email">
<Field.TextInput type="email" required value={answers.email} onValueChange={set('email')} />
</Field.Root>
<ErrorMessage.Root />
</Field.Group>
<Button.Root type="submit">Next</Button.Root>
</Stack.Root>
</Form.Root>
Next is the step's submit button, so Enter in a field moves on too. Back is type="button": it goes back without checking the step, and the person's answers stay.
Focus on each step
Move focus to the new step's heading on Next and Back. The button the person pressed is now hidden, so focus would otherwise drop to the page, and the heading tells a screen reader which step it is on. Give the heading tabIndex={-1}, so code can focus it but Tab skips it, and put the step's position in it, since the focused heading is what a screen reader reads. Focus it once the step shows, and only if focus isn't already in the step's form: a form that takes a reply moves focus to a control itself, and a screen reader shouldn't read the heading first. Form.Root makes that move before React hands control back from the commit, as Form says, so check in a microtask queued from your layout effect.
import { useLayoutEffect, useRef, useState, type SubmitEvent } from 'react';
const [step, setStep] = useState(0);
// The furthest step shown since the last reply: each gets its errors from then until its own Next.
const [reached, setReached] = useState(-1);
const forms = useRef<(HTMLFormElement | null)[]>([]);
const headings = useRef<(HTMLElement | null)[]>([]);
// Set once the person moves, so the first render doesn't take focus.
const moved = useRef(false);
useLayoutEffect(() => {
if (!moved.current) return;
// Form.Root moves focus for a reply in an update from its own layout effect, which React renders
// before this microtask, so a control it focused keeps focus.
queueMicrotask(() => {
if (!forms.current[step]?.contains(document.activeElement)) headings.current[step]?.focus();
});
}, [step]);
const go = (index: number) => {
moved.current = true;
setReached((current) => Math.max(current, index));
setFailed(null);
setStep(index);
};
// Form.Root calls onSubmit once the step's controls pass its check.
const next = (index: number) => (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
// Moving on sends the step again: drop its errors, as Form.Root asks.
setReply((current) => pick(current, STEPS[index].fields, false));
go(index + 1);
};
Mark the steps in a Steps.Root variant="progress", done before the current one and pending after it. The progress variant draws the current step as a still marker and keeps the text of done steps, where the default plan variant spins and strikes them through. The current step takes aria-current="step", and each reads its status word first.
import { Steps, type StepsStatus } from '@tounsoo/todae';
function statusOf(index: number, step: number): StepsStatus {
if (index < step) return 'done';
return index === step ? 'current' : 'pending';
}
<Steps.Root variant="progress" aria-label="Sign-up steps">
{STEPS.map((each, index) => (
<Steps.Item key={each.title} status={statusOf(index, step)}>
{each.title}
</Steps.Item>
))}
</Steps.Root>
Errors from the last step
The last step lists the answers in a DataList and sends them. A server can still turn down an answer from an earlier step, such as an address that is taken. Keep the reply's errors and a table of which step holds which control, and show the first step with an error. Give a step its entries from the first time it shows after the reply until its own Next, and none before: a hidden form would take the reply where focus can't reach it. Treat a reply with an error for a control no step holds as a failed request, never as a success, since that error has nowhere to show. Hold Back while the request runs, so the reply comes back to the answers it was sent, and show a request that fails in an Alert beside the button.
import type { FormErrors } from '@tounsoo/todae';
type Name = 'name' | 'email' | 'workspace' | 'plan';
const STEPS: readonly { title: string; fields: readonly Name[] }[] = [
{ title: 'Account', fields: ['name', 'email'] },
{ title: 'Workspace', fields: ['workspace', 'plan'] },
{ title: 'Review', fields: [] },
];
// The entries of a reply that belong, or don't belong, to a step's controls.
function pick(reply: FormErrors, fields: readonly string[], keep = true): FormErrors {
return Object.fromEntries(Object.entries(reply).filter(([name]) => fields.includes(name) === keep));
}
const create = async (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
if (sending) return;
// Hold Back and Create with aria-disabled while this is true.
setSending(true);
setFailed(null);
setReply({});
let errors: FormErrors;
try {
errors = await createWorkspace(answers);
} catch {
// Shown in an Alert.Root tone="critical" beside the button, which is announced.
return setFailed("The workspace wasn't created. Try again.");
} finally {
setSending(false);
}
const named = Object.entries(errors).filter(([, text]) => text);
if (named.length === 0) return setCreated(true);
// An error for a control no step holds can't be shown where it belongs: the request failed.
if (named.some(([name]) => !STEPS.some((each) => each.fields.includes(name as Name)))) {
// Trying again won't change the answer, so don't ask for it.
return setFailed("The workspace wasn't created, for a reason this form can't show. Contact support.");
}
const first = STEPS.findIndex((each) => each.fields.some((name) => errors[name]));
setReply(errors);
// A new reply: no step has its errors yet.
setReached(-1);
go(first);
};
That step's form is still waiting on a reply from its own Next, since a form that sent keeps waiting until the person edits it, it resets or a reply arrives. So when its errors arrive it takes them as the reply, as Form describes: it shows the message and moves focus to the control, which a screen reader reads with its error. This is why the steps stay mounted. A step keeps its entries while hidden once it has shown them, so an error the person has edited stays hidden when they go back and forth. When the person fixes that step and presses Next, the next step with an error shows and its form takes its errors the same way.
Accessible composition
Todae handles: Form.Root checks a step's controls on Next, words each problem in the page's language and moves focus to the first one. It shows a server error on its control, with aria-invalid and aria-describedby, and a form that sent moves focus to the control when the reply names it. Steps.Root is an ordered list whose items read their status word first, with aria-current="step" on the current step. A Button.Root with aria-disabled keeps focus and ignores clicks while the last step sends.
You supply: A form per step, all mounted, with hidden on the ones not showing. Focus moved to the heading of the step you show on Next and Back, unless its form has moved focus to a control, and to a heading that confirms the result at the end. Back and Create held while the last step sends, and a message for a request that fails. The step's position in its heading. A table of which step holds which control, so a server's errors reach the right form. A name for the step list and labels in your product's language.
Watch for: One Form.Root around every step, which marks a step invalid the moment it shows. Unmounting the steps that aren't showing, so a server error from the last step shows on its control but nobody moves focus there. Focus left on a hidden button after Next, which drops it to the page. Focusing the heading right after setting the step, before the step shows, which does nothing. Letting the person go back and edit while the last step sends: the edit ends the form's wait, so the reply shows its error but doesn't move focus, and the error may be about an answer they have since changed. Hiding a step with display: none on a styled wrapper such as Stack.Root, whose own display beats hidden: put hidden on the form. A disabled Next or Send button while a request runs, which drops focus; use aria-disabled. Steps that each have their own URL: the browser's back button then moves between steps too, so update the page's title and move focus the same way on each.
When to use
When: A long form a person fills in once, such as sign-up, onboarding or checkout, where steps make it shorter to read and each step can be checked on its own.
When not: A short form → one Form. Settings a person changes in any order → a page of Fieldsets, or Tabs between sections. An agent's plan that runs by itself → Steps with the default plan variant.