Zum Inhalt springen
UI Kit
Accent

Mehrstufiges Formular für React

Lange Formulare in ruhigen Schritten, als Daten beschrieben. Jeder Schritt prüft seine Felder und setzt den Fokus auf das erste offene, Zurückgehen behält jede Antwort, und der letzte Schritt zeigt auf Wunsch alles noch einmal.

Eine von 3 Components in Bewerbungen und Anmeldungen

5 Variants

Lange Formulare in ruhigen Schritten

Ein mehrstufiges Formular teilt ein langes Formular in Schritte, die man einzeln abschließt. Du beschreibst Schritte und Felder als Daten, die Component baut daraus das Formular, prüft jeden Schritt vor dem Weitergehen, setzt den Fokus auf das erste offene Feld und behält jede Antwort, wenn jemand zurückgeht.

Die Seitenleiste listet die Schritte am Desktop neben dem Formular, gut für Bewerbungen mit fünf und mehr Schritten. „Fortschritt oben“ zeigt einen Balken und passt zu kürzeren Abläufen wie einer Angebotsanfrage. „Prüfen vor dem Senden“ endet mit einer Zusammenfassung zum Prüfen und Ändern. „Schritte auf einer Seite“ hält alle Schritte als Abschnitte auf einer Seite, „Zusammenfassung live“ zeigt die bisherigen Antworten in einer Seitenspalte, nützlich für Konfiguratoren und Buchungen.

Stell die leichten Fragen an den Anfang, wer angefangen hat, macht meist weiter. Gruppiere Felder nach Thema, nicht danach, wie deine Datenbank sie speichert. Speichere Entwürfe bei langen Formularen, die Formularzustände im Kit zeigen, wie du sagst, dass der Fortschritt sicher ist.

Variants

Seitenleiste

Die Schritte stehen in einer Leiste, die sich mit jedem Schritt füllt, fertige Schritte lassen sich wieder öffnen. Auf Telefon und Tablet wird die Leiste ein Balken, der den nächsten Schritt nennt.

Im Studio öffnen

Fortschritt oben

Eine ruhige Spalte mit einem Balken oben, der sich füllt. Jeder Schritt gleitet von der Seite herein, aus der er kommt, und die Knöpfe sagen, was als Nächstes passiert.

Im Studio öffnen

Prüfen vor dem Senden

Ein letzter Schritt zeigt jede Antwort nach Schritten geordnet. Ändern öffnet den Schritt, und sein Knopf führt direkt zurück zur Übersicht.

Im Studio öffnen

Schritte auf einer Seite

Alle Schritte stehen auf einer Seite. Der offene trägt einen leisen Akzent, fertige falten sich in eine Zeile mit ihren Antworten und einem Weg zurück.

Im Studio öffnen

Zusammenfassung live

Neben dem Formular baut sich die Anfrage selbst, jede Antwort steigt in ihre Zeile und ein Ring zählt, was erledigt ist. Auf Telefon und Tablet ist die Zusammenfassung ein Balken zum Aufklappen.

Im Studio öffnen

Details

Was Mehrstufiges Formular mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/multi-step-form.tsx
Bringt mit
utilskitbuttoniconmotionrevealsectionform-flow
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 22 eigene Texte

Props

Die Schnittstelle MultiStepFormProps aus components/ui/multi-step-form.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    MultiStepFormVariant

    railprogress-topreviewaccordionlive-summary

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • stepsPflicht
    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

Eingebaute Texte

Diese Texte schreibt Mehrstufiges Formular selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 22 Texte anzeigen
nextEnglisch ContinueDeutsch Weiter
upNextEnglisch Up next · {s}Deutsch Als Nächstes · {s}
backEnglisch BackDeutsch Zurück
sendEnglisch SendDeutsch Senden
reviewEnglisch Check and sendDeutsch Prüfen und senden
reviewTextEnglisch Here is everything at a glance. Change any part, your other answers stay as they are.Deutsch Hier ist alles auf einen Blick. Ändere jeden Teil, deine übrigen Antworten bleiben, wie sie sind.
changeEnglisch ChangeDeutsch Ändern
changeStepEnglisch Change {s}Deutsch {s} ändern
toSummaryEnglisch Back to the overviewDeutsch Zurück zur Übersicht
keptEnglisch Going back keeps every answerDeutsch Beim Zurückgehen bleibt jede Antwort erhalten
stepsEnglisch StepsDeutsch Schritte
openEnglisch Still openDeutsch Noch offen
answeredEnglisch {n} of {total} answeredDeutsch {n} von {total} beantwortet
summaryEnglisch Your requestDeutsch Deine Anfrage
showSummaryEnglisch Show your requestDeutsch Deine Anfrage zeigen
hideSummaryEnglisch Hide your requestDeutsch Deine Anfrage ausblenden
doneTitleEnglisch Thank you, your request has arrivedDeutsch Danke, deine Anfrage ist angekommen
doneTextEnglisch We read every request ourselves and reply within one working day.Deutsch Wir lesen jede Anfrage selbst und antworten innerhalb eines Werktags.
againEnglisch Start a new requestDeutsch Neue Anfrage starten
yesEnglisch YesDeutsch Ja
emptyEnglisch Not givenDeutsch Nicht angegeben
stepDoneEnglisch doneDeutsch erledigt

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-controls, aria-current, aria-expanded, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 655 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Mehrstufiges Formular?

Mit aiw add @ai-website/multi-step-form. Der Befehl legt die Datei components/ui/multi-step-form.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, reveal, section und form-flow. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Mehrstufiges Formular?

5 Varianten, Seitenleiste, Fortschritt oben, Prüfen vor dem Senden, Schritte auf einer Seite und Zusammenfassung live. Du wählst sie mit der Prop variant, zum Beispiel variant="progress-top".

Kann ich die Texte von Mehrstufiges Formular übersetzen?

Ja. Mehrstufiges Formular bringt 22 eigene Texte auf Deutsch und Englisch mit, etwa „Weiter“, „Zurück“ und „Senden“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Mehrstufiges Formular einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Mehrstufiges Formular mit aiw_components und installieren sie mit aiw_add @ai-website/multi-step-form. Nenn im Prompt die Variante, etwa variant="rail", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Mehrstufiges Formular ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden MultiStepForm-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert