Skip to content
UI Kit
Accent color

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

5 variants

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

Side rail

The steps sit in a rail that fills as the visitor moves on, finished steps can be opened again. On phones and tablets the rail becomes a bar with the next step named.

Open in Studio

Progress on top

One calm column with a bar that fills on top. Each step slides in from the side it comes from, and the actions say what happens next.

Open in Studio

Review before sending

A last step shows every answer grouped by step. Change opens that step, and its button leads straight back to the overview.

Open in Studio

Steps on one page

All steps stand on one page. The open one has a soft accent, finished ones fold into a single line with their answers and a way back in.

Open in Studio

Live summary

Beside the form the request builds itself, every answer rises into its line and a ring counts what is done. On phones and tablets the summary is a bar that opens.

Open in Studio

Details

What Multi-step form brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/multi-step-form.tsx
Brings along
utilskitbuttoniconmotionrevealsectionform-flow
npm packages
None
Languages
English and German, 22 texts of its own

Props

The MultiStepFormProps interface from components/ui/multi-step-form.tsx, with the comments from the code.

  • variantoptional
    MultiStepFormVariant

    railprogress-topreviewaccordionlive-summary

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • stepsrequired
    FormStep[]
  • defaultValuesoptional
    FlowValues
  • initialStepoptional
    number

    Step to open with, e.g. the review step (its index is steps.length)

  • submitLabeloptional
    string

    Label of the last button, e.g. "Send request"

  • noteoptional
    string

    Quiet 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

  • summaryTitleoptional
    string

    live-summary: title of the summary card

  • actionoptional
    string
  • onSubmitoptional
    (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
nextEnglish ContinueGerman Weiter
upNextEnglish Up next · {s}German Als Nächstes · {s}
backEnglish BackGerman Zurück
sendEnglish SendGerman Senden
reviewEnglish Check and sendGerman Prüfen und senden
reviewTextEnglish 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.
changeEnglish ChangeGerman Ändern
changeStepEnglish Change {s}German {s} ändern
toSummaryEnglish Back to the overviewGerman Zurück zur Übersicht
keptEnglish Going back keeps every answerGerman Beim Zurückgehen bleibt jede Antwort erhalten
stepsEnglish StepsGerman Schritte
openEnglish Still openGerman Noch offen
answeredEnglish {n} of {total} answeredGerman {n} von {total} beantwortet
summaryEnglish Your requestGerman Deine Anfrage
showSummaryEnglish Show your requestGerman Deine Anfrage zeigen
hideSummaryEnglish Hide your requestGerman Deine Anfrage ausblenden
doneTitleEnglish Thank you, your request has arrivedGerman Danke, deine Anfrage ist angekommen
doneTextEnglish We read every request ourselves and reply within one working day.German Wir lesen jede Anfrage selbst und antworten innerhalb eines Werktags.
againEnglish Start a new requestGerman Neue Anfrage starten
yesEnglish YesGerman Ja
emptyEnglish Not givenGerman Nicht angegeben
stepDoneEnglish doneGerman 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
components/ui/multi-step-form.tsx
'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.

Often searched

Jump to

Copied to the clipboard