Multi step form for React
Long forms cut into calm steps, described as data. Every step checks its own fields and focuses the first open one, going back keeps every answer, and the last step can show everything for a final look.
One of 3 components in Applications and sign-ups
Long forms in calm steps
A multi step form cuts a long form into steps people can finish one at a time. You describe the steps and fields as data, and the component builds the form, checks each step before moving on, focuses the first open field and keeps every answer when people go back.
Side rail lists the steps beside the form on desktop, good for applications with five or more steps. Progress on top shows a bar and fits shorter flows such as a quote request. Review before sending ends with a summary to check and edit. Steps on one page keeps every step on one page with sections, Live summary shows the answers so far in a side panel, useful for configurators and bookings.
Put the easy questions first, people who have started tend to finish. Group fields by topic, not by how your database stores them. Save drafts when the form is long, the form states in the kit show how to tell people their progress is safe.
Variants
Details
What Multi-step form brings along, read from the registry and the source.
- Category
- Applications and sign-ups
- Area
- Content
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/multi-step-form.tsx
- npm packages
- None
- Languages
- English and German, 22 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MultiStepFormProps interface from components/ui/multi-step-form.tsx, with the comments from the code.
variantoptionalMultiStepFormVariantrailprogress-topreviewaccordionlive-summary
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodestepsrequiredFormStep[]defaultValuesoptionalFlowValuesinitialStepoptionalnumberStep to open with, e.g. the review step (its index is steps.length)
submitLabeloptionalstringLabel of the last button, e.g. "Send request"
noteoptionalstringQuiet line next to the actions, e.g. "Takes about two minutes"
doneoptional{ title: string; text?: string; steps?: DoneStep[]; /** Label of the button that starts over */ again?: string }The confirmation after sending
summaryTitleoptionalstringlive-summary: title of the summary card
actionoptionalstringonSubmitoptional(values: FlowValues) => void
Built-in texts
Multi-step form writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 22 texts
| Key | English | German |
|---|---|---|
| next | English Continue | German Weiter |
| upNext | English Up next · {s} | German Als Nächstes · {s} |
| back | English Back | German Zurück |
| send | English Send | German Senden |
| review | English Check and send | German Prüfen und senden |
| reviewText | English Here is everything at a glance. Change any part, your other answers stay as they are. | German Hier ist alles auf einen Blick. Ändere jeden Teil, deine übrigen Antworten bleiben, wie sie sind. |
| change | English Change | German Ändern |
| changeStep | English Change {s} | German {s} ändern |
| toSummary | English Back to the overview | German Zurück zur Übersicht |
| kept | English Going back keeps every answer | German Beim Zurückgehen bleibt jede Antwort erhalten |
| steps | English Steps | German Schritte |
| open | English Still open | German Noch offen |
| answered | English {n} of {total} answered | German {n} von {total} beantwortet |
| summary | English Your request | German Deine Anfrage |
| showSummary | English Show your request | German Deine Anfrage zeigen |
| hideSummary | English Hide your request | German Deine Anfrage ausblenden |
| doneTitle | English Thank you, your request has arrived | German Danke, deine Anfrage ist angekommen |
| doneText | English We read every request ourselves and reply within one working day. | German Wir lesen jede Anfrage selbst und antworten innerhalb eines Werktags. |
| again | English Start a new request | German Neue Anfrage starten |
| yes | English Yes | German Ja |
| empty | English Not given | German Nicht angegeben |
| stepDone | English done | German erledigt |
Accessibility
Read from the source of the component.
- Sets aria-controls, aria-current, aria-expanded, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 655 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { checkFields, DonePanel, FieldView, fieldText, FlowHead, StepLine, useFlowTexts, useFocusFirstError, type DoneStep, type FieldSpec, type FlowValue, type FlowValues } from '@/components/ui/form-flow';
/*
* A long form cut into steps. Every step checks its own fields before it lets
* the visitor on, focus lands on the first open field, and going back never
* loses an answer: all values live in one place for the whole flow.
*
* rail steps in a side rail that fills as you go, a bar on phones
* progress-top one calm column, a bar on top, steps slide in from the side
* review a last step that shows every answer with a way to change it
* accordion all steps on one page, finished ones fold into one line
* live-summary the request builds itself in a card beside the form
*/
export type MultiStepFormVariant = 'rail' | 'progress-top' | 'review' | 'accordion' | 'live-summary';
export interface FormStep {
id: string;
title: string;
description?: string;
fields: FieldSpec[];
}
export interface MultiStepFormProps {
variant?: MultiStepFormVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
steps: FormStep[];
defaultValues?: FlowValues;
/** Step to open with, e.g. the review step (its index is steps.length) */
initialStep?: number;
/** Label of the last button, e.g. "Send request" */
submitLabel?: string;
/** Quiet line next to the actions, e.g. "Takes about two minutes" */
note?: string;
/** The confirmation after sending */
done?: { title: string; text?: string; steps?: DoneStep[]; /** Label of the button that starts over */ again?: string };
/** live-summary: title of the summary card */
summaryTitle?: string;
action?: string;
onSubmit?: (values: FlowValues) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
next: 'Continue',
upNext: 'Up next · {s}',
back: 'Back',
send: 'Send',
review: 'Check and send',
reviewText: 'Here is everything at a glance. Change any part, your other answers stay as they are.',
change: 'Change',
changeStep: 'Change {s}',
toSummary: 'Back to the overview',
kept: 'Going back keeps every answer',
steps: 'Steps',
open: 'Still open',
answered: '{n} of {total} answered',
summary: 'Your request',
showSummary: 'Show your request',
hideSummary: 'Hide your request',
doneTitle: 'Thank you, your request has arrived',
doneText: 'We read every request ourselves and reply within one working day.',
again: 'Start a new request',
yes: 'Yes',
empty: 'Not given',
stepDone: 'done',
},
de: {
next: 'Weiter',
upNext: 'Als Nächstes · {s}',
back: 'Zurück',
send: 'Senden',
review: 'Prüfen und senden',
reviewText: 'Hier ist alles auf einen Blick. Ändere jeden Teil, deine übrigen Antworten bleiben, wie sie sind.',
change: 'Ändern',
changeStep: '{s} ändern',
toSummary: 'Zurück zur Übersicht',
kept: 'Beim Zurückgehen bleibt jede Antwort erhalten',
steps: 'Schritte',
open: 'Noch offen',
answered: '{n} von {total} beantwortet',
summary: 'Deine Anfrage',
showSummary: 'Deine Anfrage zeigen',
hideSummary: 'Deine Anfrage ausblenden',
doneTitle: 'Danke, deine Anfrage ist angekommen',
doneText: 'Wir lesen jede Anfrage selbst und antworten innerhalb eines Werktags.',
again: 'Neue Anfrage starten',
yes: 'Ja',
empty: 'Nicht angegeben',
stepDone: 'erledigt',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* The engine every variant shares */
/* ------------------------------------------------------------------ */
function useFlow(p: MultiStepFormProps, withReview: boolean) {
const ft = useFlowTexts();
const last = withReview ? p.steps.length : p.steps.length - 1;
const [values, setValues] = useState<FlowValues>(p.defaultValues ?? {});
const [step, setStep] = useState(Math.min(p.initialStep ?? 0, last));
const [reached, setReached] = useState(Math.min(p.initialStep ?? 0, last));
const [errors, setErrors] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState(0);
const [dir, setDir] = useState(1);
const [done, setDone] = useState(false);
// Came from the overview to change one step: the step button leads back there
const [returnTo, setReturnTo] = useState<number | null>(null);
const set = (name: string, v: FlowValue) => {
setValues((x) => ({ ...x, [name]: v }));
if (errors[name]) setErrors((e) => Object.fromEntries(Object.entries(e).filter(([k]) => k !== name)));
};
const check = (i: number) => checkFields(p.steps[i]?.fields ?? [], values, ft);
const go = (i: number) => {
setDir(i >= step ? 1 : -1);
setStep(i);
setReached((r) => Math.max(r, i));
setErrors({});
};
/** Validates the current step, then moves on, back to the overview, or sends */
const next = () => {
if (step < p.steps.length) {
const e = check(step);
if (Object.keys(e).length) {
setErrors(e);
setAttempt((a) => a + 1);
return false;
}
}
if (returnTo !== null) {
setReturnTo(null);
go(returnTo);
return true;
}
if (step < last) {
go(step + 1);
return true;
}
p.onSubmit?.(values);
setDone(true);
return true;
};
/** For one-page variants: check every step, open the first with a problem */
const all = () => {
for (let i = 0; i < p.steps.length; i++) {
const e = check(i);
if (Object.keys(e).length) return { i, e };
}
return null;
};
const reset = () => {
setValues({});
setStep(0);
setReached(0);
setDone(false);
setErrors({});
};
return { values, set, step, go, next, back: () => step > 0 && go(step - 1), reached, errors, setErrors, attempt, setAttempt, dir, done, setDone, last, returnTo, setReturnTo, reset, check, all };
}
type Flow = ReturnType<typeof useFlow>;
/** Slides the step in from the side it came from, after the first render only */
function useStepEnter(step: number, dir: number) {
const ref = useRef<HTMLDivElement>(null);
const first = useRef(true);
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
const el = ref.current;
if (!el) return;
el.querySelector<HTMLElement>('[data-step-title]')?.focus({ preventScroll: true });
if (prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ opacity: 0, transform: `translateX(${dir * 18}px)` }, { opacity: 1, transform: 'translateX(0)' }], { duration: 420, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}, [step, dir]);
return ref;
}
function StepFields({ s, flow, prefix }: { s: FormStep; flow: Flow; prefix: string }) {
return (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{s.fields.map((f) => (
<FieldView key={f.name} f={f} id={`${prefix}-${f.name}`} value={flow.values[f.name]} onChange={(v) => flow.set(f.name, v)} error={flow.errors[f.name]} />
))}
</div>
);
}
function StepTitle({ s, size = 'md' }: { s: { title: string; description?: string }; size?: 'md' | 'lg' }) {
return (
<div className="mb-6">
<h3 data-step-title tabIndex={-1} className={cn('text-balance font-semibold tracking-[-0.02em] text-foreground outline-none', size === 'lg' ? 'text-h3' : 'text-[19px] leading-snug sm:text-[21px]')}>
{s.title}
</h3>
{s.description && <p className="mt-1.5 max-w-[36rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{s.description}</p>}
</div>
);
}
function Actions({ flow, t, p, note, center }: { flow: Flow; t: Texts; p: MultiStepFormProps; note?: string; center?: boolean }) {
const isLast = flow.step === flow.last;
const label = flow.returnTo !== null ? t.toSummary : isLast ? (p.submitLabel ?? t.send) : t.next;
return (
<div className={cn('mt-8 flex flex-col-reverse gap-3 border-t border-border pt-6 sm:flex-row sm:items-center', center ? 'sm:justify-center' : 'sm:justify-between')}>
<div className="flex items-center gap-4 max-sm:justify-center">
{flow.step > 0 && flow.returnTo === null ? (
<Button type="button" variant="ghost" iconLeft="arrow-left" onClick={flow.back} className="-ms-5 max-sm:ms-0">
{t.back}
</Button>
) : null}
{note && <p className="text-[13px] text-muted-foreground">{note}</p>}
</div>
<Button type="submit" icon={isLast ? 'send' : 'arrow-right'} className="max-sm:w-full">
{label}
</Button>
</div>
);
}
function Done({ p, t, flow }: { p: MultiStepFormProps; t: Texts; flow: Flow }) {
return (
<DonePanel
title={p.done?.title ?? t.doneTitle}
text={p.done?.text ?? t.doneText}
steps={p.done?.steps}
actions={
<Button variant="outline" iconLeft="rotate-ccw" onClick={flow.reset}>
{p.done?.again ?? t.again}
</Button>
}
/>
);
}
/** A form for one step, Enter moves on, errors focus their field */
function StepForm({ flow, p, children, className }: { flow: Flow; p: MultiStepFormProps; children: React.ReactNode; className?: string }) {
const ref = useRef<HTMLFormElement>(null);
useFocusFirstError(ref, flow.attempt);
return (
<form
ref={ref}
action={p.action}
method="post"
noValidate
className={className}
onSubmit={(e) => {
e.preventDefault();
flow.next();
}}
>
{children}
</form>
);
}
/* ------------------------------------------------------------------ */
/* Review */
/* ------------------------------------------------------------------ */
function Overview({ p, t, flow, compact }: { p: MultiStepFormProps; t: Texts; flow: Flow; compact?: boolean }) {
const locale = useLocale();
return (
<Stagger step={80} as="div" className="grid gap-3">
{p.steps.map((s, i) => (
<section key={s.id} aria-labelledby={`ov-${s.id}`} className="rounded-xl border border-border bg-surface">
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5 sm:px-5">
<h4 id={`ov-${s.id}`} className="text-[13px] font-semibold text-foreground">
<span className="me-2 tabular-nums text-subtle-foreground">{i + 1}</span>
{s.title}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
iconLeft="edit"
aria-label={t.changeStep.replace('{s}', s.title)}
onClick={() => {
flow.setReturnTo(flow.step);
flow.go(i);
}}
className="-me-2"
>
{t.change}
</Button>
</div>
<dl className={cn('grid grid-cols-1 gap-x-6 px-4 py-3 sm:px-5', !compact && 'sm:grid-cols-2')}>
{s.fields.map((f) => {
const text = fieldText(f, flow.values[f.name], t.yes, locale);
return (
<div key={f.name} className={cn('flex min-w-0 items-baseline justify-between gap-4 py-1.5 sm:block', (f.kind === 'textarea' || f.kind === 'consent') && 'sm:col-span-2')}>
<dt className="shrink-0 text-[12px] text-muted-foreground">{f.label}</dt>
<dd className={cn('min-w-0 break-words text-end text-[14px] leading-snug sm:mt-0.5 sm:text-start', text ? 'text-foreground' : 'text-subtle-foreground')}>{text || t.empty}</dd>
</div>
);
})}
</dl>
</section>
))}
</Stagger>
);
}
/* ------------------------------------------------------------------ */
/* rail */
/* ------------------------------------------------------------------ */
function Rail({ p, t, flow, withReview }: { p: MultiStepFormProps; t: Texts; flow: Flow; withReview: boolean }) {
const items = [...p.steps.map((s) => s.title), ...(withReview ? [t.review] : [])];
const fill = items.length > 1 ? flow.step / (items.length - 1) : 1;
return (
<nav aria-label={t.steps} className="relative">
<span aria-hidden className="absolute bottom-5 start-[15px] top-5 w-px bg-border" />
<span aria-hidden className="absolute start-[15px] top-5 w-px origin-top bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ height: 'calc(100% - 2.5rem)', transform: `scaleY(${round(fill, 3)})` }} />
<ol className="relative grid gap-1">
{items.map((label, i) => {
const state = i < flow.step ? 'done' : i === flow.step ? 'current' : 'later';
const reachable = i <= flow.reached && i !== flow.step && !flow.done;
const inner = (
<>
<span
aria-hidden
className={cn(
'relative grid size-8 shrink-0 place-items-center rounded-full border text-[13px] font-semibold tabular-nums transition-[background-color,border-color,color,box-shadow] duration-300',
state === 'done' && 'border-primary bg-primary text-primary-foreground',
state === 'current' && 'border-primary bg-card text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))] shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)]',
state === 'later' && 'border-border bg-card text-subtle-foreground',
)}
>
{state === 'done' ? <Icon name="check" size={15} strokeWidth={2.4} /> : i + 1}
</span>
<span className="min-w-0">
<span className={cn('block text-[14px] font-medium leading-5', state === 'later' ? 'text-muted-foreground' : 'text-foreground')}>{label}</span>
{state === 'done' && <span className="sr-only">, {t.stepDone}</span>}
</span>
</>
);
return (
<li key={label}>
{reachable ? (
<button type="button" onClick={() => flow.go(i)} className="group/rail flex min-h-12 w-full items-center gap-3.5 rounded-lg pe-3 text-start outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_25%,transparent)]">
{inner}
</button>
) : (
<div aria-current={state === 'current' ? 'step' : undefined} className="flex min-h-12 items-center gap-3.5 pe-3">
{inner}
</div>
)}
</li>
);
})}
</ol>
</nav>
);
}
function RailView({ p, t }: { p: MultiStepFormProps; t: Texts }) {
const flow = useFlow(p, false);
const prefix = useId();
const ref = useStepEnter(flow.step, flow.dir);
const s = p.steps[flow.step];
return (
<Reveal effect="scale" className="kit-card overflow-hidden lg:grid lg:grid-cols-[17rem_minmax(0,1fr)]">
<aside className="hidden border-e border-border bg-surface p-6 lg:block">
<Rail p={p} t={t} flow={flow} withReview={false} />
<p className="mt-6 flex items-start gap-2 border-t border-border pt-5 text-[13px] leading-5 text-muted-foreground">
<Icon name="history" size={15} className="mt-0.5 shrink-0" />
{t.kept}
</p>
</aside>
<div className="min-w-0 p-6 sm:p-8 lg:p-10">
{flow.done ? (
<Done p={p} t={t} flow={flow} />
) : (
<>
<StepLine current={flow.step} total={p.steps.length} title={p.steps[flow.step + 1] ? t.upNext.replace('{s}', p.steps[flow.step + 1].title) : undefined} className="mb-7 lg:hidden" />
<StepForm flow={flow} p={p}>
<div ref={ref}>
<StepTitle s={s} />
<StepFields s={s} flow={flow} prefix={prefix} />
</div>
<Actions flow={flow} t={t} p={p} note={p.note} />
</StepForm>
</>
)}
</div>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* progress-top and review */
/* ------------------------------------------------------------------ */
function ColumnView({ p, t, withReview }: { p: MultiStepFormProps; t: Texts; withReview: boolean }) {
const flow = useFlow(p, withReview);
const prefix = useId();
const ref = useStepEnter(flow.step, flow.dir);
const total = p.steps.length + (withReview ? 1 : 0);
const onReview = withReview && flow.step === p.steps.length;
const s = onReview ? { title: t.review, description: t.reviewText } : p.steps[flow.step];
return (
<Reveal effect="rise" className="mx-auto w-full max-w-[44rem]">
<div className="kit-card p-6 sm:p-10">
{flow.done ? (
<Done p={p} t={t} flow={flow} />
) : (
<>
<StepLine current={flow.step} total={total} className="mb-8" />
<StepForm flow={flow} p={p}>
<div ref={ref}>
<StepTitle s={s} size="lg" />
{onReview ? <Overview p={p} t={t} flow={flow} /> : <StepFields s={p.steps[flow.step]} flow={flow} prefix={prefix} />}
</div>
<Actions flow={flow} t={t} p={p} note={flow.returnTo !== null ? undefined : flow.step > 0 && !onReview ? t.kept : p.note} />
</StepForm>
</>
)}
</div>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* accordion */
/* ------------------------------------------------------------------ */
function AccordionView({ p, t }: { p: MultiStepFormProps; t: Texts }) {
const locale = useLocale();
const flow = useFlow(p, false);
const prefix = useId();
const formRef = useRef<HTMLFormElement>(null);
useFocusFirstError(formRef, flow.attempt);
if (flow.done)
return (
<div className="kit-card mx-auto max-w-[48rem] p-6 sm:p-10">
<Done p={p} t={t} flow={flow} />
</div>
);
return (
<form
ref={formRef}
action={p.action}
method="post"
noValidate
onSubmit={(e) => {
e.preventDefault();
flow.next();
}}
className="mx-auto max-w-[48rem]"
>
<Stagger step={90} as="ol" className="grid gap-3">
{p.steps.map((s, i) => {
const open = i === flow.step;
const finished = i < flow.reached;
const summary = s.fields
.map((f) => fieldText(f, flow.values[f.name], t.yes, locale))
.filter(Boolean)
.join(' · ');
return (
<li key={s.id} className={cn('kit-card overflow-hidden transition-[box-shadow,border-color] duration-300', open && 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_30%,transparent),var(--shadow-md)]')}>
<div className="flex min-h-16 items-center gap-4 px-5 py-4 sm:px-6">
<span
aria-hidden
className={cn(
'grid size-8 shrink-0 place-items-center rounded-full border text-[13px] font-semibold tabular-nums transition-colors duration-300',
finished && !open ? 'border-primary bg-primary text-primary-foreground' : open ? 'border-primary text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'border-border text-subtle-foreground',
)}
>
{finished && !open ? <Icon name="check" size={15} strokeWidth={2.4} /> : i + 1}
</span>
<div className="min-w-0 flex-1">
<h3 data-step-title={open || undefined} tabIndex={-1} className={cn('text-[16px] font-semibold leading-snug outline-none', open || finished ? 'text-foreground' : 'text-muted-foreground')}>
{s.title}
</h3>
{!open && finished && summary && <p className="mt-0.5 truncate text-[13px] text-muted-foreground">{summary}</p>}
</div>
{!open && i <= flow.reached && (
<Button type="button" variant="ghost" size="sm" aria-label={t.changeStep.replace('{s}', s.title)} onClick={() => flow.go(i)} className="-me-2">
{t.change}
</Button>
)}
</div>
{/* Opens with a soft height: grid rows from 0fr to 1fr */}
<div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={!open || undefined}>
<div className="relative min-h-0 overflow-hidden">
<div className="border-t border-border px-5 pb-6 pt-5 sm:px-6">
{s.description && <p className="mb-5 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{s.description}</p>}
<StepFields s={s} flow={flow} prefix={prefix} />
<div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[13px] text-muted-foreground max-sm:text-center">{i > 0 && i < p.steps.length - 1 ? t.kept : p.note}</p>
<Button type="submit" icon={i === p.steps.length - 1 ? 'send' : 'arrow-down'} className="max-sm:w-full">
{i === p.steps.length - 1 ? (p.submitLabel ?? t.send) : t.next}
</Button>
</div>
</div>
</div>
</div>
</li>
);
})}
</Stagger>
</form>
);
}
/* ------------------------------------------------------------------ */
/* live-summary */
/* ------------------------------------------------------------------ */
function SummaryList({ p, t, flow }: { p: MultiStepFormProps; t: Texts; flow: Flow }) {
const locale = useLocale();
return (
<dl className="grid gap-0">
{p.steps.map((s) => (
<div key={s.id} className="border-t border-border py-3 first:border-t-0 first:pt-0">
<dt className="text-[12px] font-medium text-subtle-foreground">{s.title}</dt>
<dd className="mt-1 grid gap-1">
{s.fields
.filter((f) => f.kind !== 'consent')
.map((f) => {
const text = fieldText(f, flow.values[f.name], t.yes, locale);
return (
<p key={f.name} className="flex items-baseline justify-between gap-3 text-[13.5px] leading-snug">
<span className="shrink-0 text-muted-foreground">{f.label}</span>
{text ? (
<span key={text} className="kit-rise min-w-0 truncate text-end font-medium text-foreground">
{text}
</span>
) : (
<span className="mt-1 h-1 w-6 shrink-0 self-center rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
<span className="sr-only">{t.open}</span>
</span>
)}
</p>
);
})}
</dd>
</div>
))}
</dl>
);
}
function LiveSummaryView({ p, t }: { p: MultiStepFormProps; t: Texts }) {
const locale = useLocale();
const flow = useFlow(p, false);
const prefix = useId();
const ref = useStepEnter(flow.step, flow.dir);
const [open, setOpen] = useState(false);
const s = p.steps[flow.step];
const fields = p.steps.flatMap((x) => x.fields).filter((f) => f.kind !== 'consent');
const answered = fields.filter((f) => fieldText(f, flow.values[f.name], t.yes, locale)).length;
const share = fields.length ? answered / fields.length : 0;
const count = t.answered.replace('{n}', String(answered)).replace('{total}', String(fields.length));
const r = 15;
const circ = round(2 * Math.PI * r, 2);
const ring = (
<svg viewBox="0 0 36 36" width={36} height={36} aria-hidden className="shrink-0 -rotate-90">
<circle cx="18" cy="18" r={r} fill="none" stroke="color-mix(in oklab, var(--foreground) 12%, transparent)" strokeWidth="3" />
<circle cx="18" cy="18" r={r} fill="none" stroke="var(--primary)" strokeWidth="3" strokeLinecap="round" strokeDasharray={circ} strokeDashoffset={round(circ * (1 - share), 2)} className="transition-[stroke-dashoffset] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" />
</svg>
);
return (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start lg:gap-8">
<Reveal effect="rise" className="kit-card min-w-0 p-6 sm:p-8 lg:p-10">
{flow.done ? (
<Done p={p} t={t} flow={flow} />
) : (
<>
<StepLine current={flow.step} total={p.steps.length} className="mb-8" />
<StepForm flow={flow} p={p}>
<div ref={ref}>
<StepTitle s={s} />
<StepFields s={s} flow={flow} prefix={prefix} />
</div>
<Actions flow={flow} t={t} p={p} note={p.note} />
</StepForm>
</>
)}
</Reveal>
{/* Desktop: a card that stays in view. Phone and tablet: a bar that opens the same list. */}
<Reveal effect="rise" delay={120} className="lg:sticky lg:top-24">
<aside aria-label={p.summaryTitle ?? t.summary} className="kit-card overflow-hidden">
<div className="flex items-center gap-3 border-b border-border bg-surface px-5 py-4 max-lg:hidden">
{ring}
<div className="min-w-0">
<p className="text-[15px] font-semibold text-foreground">{p.summaryTitle ?? t.summary}</p>
<p aria-live="polite" className="text-[13px] tabular-nums text-muted-foreground">
{count}
</p>
</div>
</div>
<button type="button" aria-expanded={open} aria-controls={`${prefix}-summary`} onClick={() => setOpen((o) => !o)} className="flex min-h-14 w-full items-center gap-3 px-5 py-3 text-start outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] lg:hidden">
{ring}
<span className="min-w-0 flex-1">
<span className="block text-[15px] font-semibold text-foreground">{open ? t.hideSummary : t.showSummary}</span>
<span className="block text-[13px] tabular-nums text-muted-foreground">{count}</span>
</span>
<Icon name="chevron-down" size={18} className={cn('shrink-0 text-muted-foreground transition-transform duration-300', open && 'rotate-180')} />
</button>
<div id={`${prefix}-summary`} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none lg:grid-rows-[1fr]', open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
<div className="relative min-h-0 overflow-hidden">
<div className="px-5 py-4 max-lg:border-t max-lg:border-border">
<SummaryList p={p} t={t} flow={flow} />
</div>
</div>
</div>
</aside>
</Reveal>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* A form in steps, described as data.
* <MultiStepForm variant="rail" steps={[{ id: 'project', title: 'Your project', fields: [{ name: 'type', label: 'Type', kind: 'choice', options: […] }] }]} />
*/
export function MultiStepForm(props: MultiStepFormProps) {
const { variant = 'rail', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
const center = variant === 'progress-top' || variant === 'review' || variant === 'accordion';
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align={center ? 'center' : 'left'} className="mb-10 sm:mb-14" />
{variant === 'rail' && <RailView p={props} t={t} />}
{variant === 'progress-top' && <ColumnView p={props} t={t} withReview={false} />}
{variant === 'review' && <ColumnView p={props} t={t} withReview />}
{variant === 'accordion' && <AccordionView p={props} t={t} />}
{variant === 'live-summary' && <LiveSummaryView p={props} t={t} />}
</Section>
);
}Frequent questions
How do I install Multi-step form?
With aiw add @ai-website/multi-step-form. The command puts components/ui/multi-step-form.tsx into your project, the code is yours from then on. It brings along button, icon, motion, reveal, section and form-flow. It needs no further npm packages.
Which variants does Multi-step form have?
5 variants, Side rail, Progress on top, Review before sending, Steps on one page and Live summary. You pick one with the variant prop, for example variant="progress-top".
Can I translate the texts of Multi-step form?
Yes. Multi-step form carries 22 texts of its own in English and German, such as “Continue”, “Back” and “Send”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Multi-step form?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Multi-step form with aiw_components and install it with aiw_add @ai-website/multi-step-form. Name the variant in your prompt, for example variant="rail", and the assistant writes your content straight into the props.
Can I adjust Multi-step form without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching MultiStepForm code to copy.