Multi-step Form
A multi-step form container with direction-aware sliding transitions and a switchable progress indicator style.
This is a WebberUI Pro component
The install command below needs a licence to run; without one it returns 401.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add "https://webberui.com/r/multi-step-form.en.json?t=<install token>"Multi-step Form is a React multi-step form (form wizard) container built with Motion (motion/react) and Tailwind CSS. You declare steps with MultiStepFormStep, steps slide in the direction you move, and the progress indicator comes in bar, dots or numbered-steps styles. The component handles step flow and transitions only; fields and validation stay in your own form logic.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<MultiStepForm/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- SaaS sign-up split into account, company details and preferences
- Checkout broken into shipping address, delivery method and payment review
- New-user onboarding surveys that ask one or two questions per screen
- Long registration or application forms that read better in sections
- Not a good fit: if you only need to show progress and the content does not swap inside one container, use Stepper
Installation
npx webberui@latest add "https://webberui.com/r/multi-step-form.en.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/multi-step-form.
Usage
Declare each step with <MultiStepFormStep>; the container makes a direction-aware sliding transition based on the current step and renders the progress indicator and the back / next control bar automatically.
import {
MultiStepForm,
MultiStepFormStep,
} from "@/components/ui/multi-step-form";
<MultiStepForm variant="steps" onComplete={() => console.log("done")}>
<MultiStepFormStep label="Account">{/* ...fields */}</MultiStepFormStep>
<MultiStepFormStep label="Profile">{/* ...fields */}</MultiStepFormStep>
<MultiStepFormStep label="Confirm">{/* ...summary */}</MultiStepFormStep>
</MultiStepForm>;Custom control bar
Set controls to false and use the useMultiStepForm() hook to assemble your own navigation anywhere (so you can validate before calling next()):
import { useMultiStepForm } from "@/components/ui/multi-step-form";
function Footer() {
const { back, next, isFirst, isLast } = useMultiStepForm();
return (
<div>
<button onClick={back} disabled={isFirst}>
Back
</button>
<button onClick={next}>{isLast ? "Submit" : "Next"}</button>
</div>
);
}Controlled mode
Pass step and onStepChange to take full control of the current step — ideal for gating steps behind form validation.
const [step, setStep] = React.useState(0);
<MultiStepForm step={step} onStepChange={(next) => setStep(next)}>
{/* ...steps */}
</MultiStepForm>;Examples
Only advance when the step is valid
In controlled mode, onStepChange receives the direction. When moving forward (direction === 1), check the current step and skip updating step if it fails. Pressing "Done" on the last step calls onComplete and does not go through onStepChange.
"use client";
import * as React from "react";
import {
MultiStepForm,
MultiStepFormStep,
} from "@/components/ui/multi-step-form";
export function SignupForm() {
const [step, setStep] = React.useState(0);
const [email, setEmail] = React.useState("");
const [error, setError] = React.useState("");
return (
<MultiStepForm
step={step}
onStepChange={(next, direction) => {
if (direction === 1 && step === 0 && !email.includes("@")) {
setError("Enter a valid email address");
return;
}
setError("");
setStep(next);
}}
onComplete={() => console.log("submit", { email })}
>
<MultiStepFormStep label="Account">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
className="w-full rounded-lg border px-3 py-2 text-sm"
/>
{error ? <p className="mt-2 text-sm text-red-600">{error}</p> : null}
</MultiStepFormStep>
<MultiStepFormStep label="Company">{/* name, team size */}</MultiStepFormStep>
<MultiStepFormStep label="Review">{/* summary */}</MultiStepFormStep>
</MultiStepForm>
);
}Progress bar style with custom button text
variant="bar" shows the current step title and an "x / y" counter; the three label props change the built-in controls.
<MultiStepForm
variant="bar"
backLabel="Previous"
nextLabel="Continue"
completeLabel="Place order"
onComplete={placeOrder}
>
<MultiStepFormStep label="Shipping">{/* ... */}</MultiStepFormStep>
<MultiStepFormStep label="Delivery">{/* ... */}</MultiStepFormStep>
<MultiStepFormStep label="Payment">{/* ... */}</MultiStepFormStep>
</MultiStepForm>Start from a given step
In uncontrolled mode, defaultStep sets the initial step, for example when a user comes back to finish a saved draft. Out-of-range indexes are clamped to a valid value.
<MultiStepForm defaultStep={savedDraft.step} variant="dots">
{/* ...steps */}
</MultiStepForm>Props
MultiStepForm
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | The step declarations, all <MultiStepFormStep> |
step | number | — | Controlled current step index; when omitted, it is uncontrolled |
defaultStep | number | 0 | Initial step index in uncontrolled mode |
onStepChange | (step: number, direction: 1 | -1) => void | — | Fires when the step changes, with the new index and the direction |
onComplete | () => void | — | Fires when "Done" is pressed on the last step |
variant | "bar" | "dots" | "steps" | false | "steps" | Progress indicator style; false hides it |
controls | boolean | true | Whether to render the built-in control bar |
backLabel | string | "Back" | Text of the back button |
nextLabel | string | "Next" | Text of the next button |
completeLabel | string | "Done" | Text of the final-step button |
MultiStepFormStep
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Step title shown in the progress indicator |
children | ReactNode | — | Step content |
className | string | — | Appended to the step container's className |
useMultiStepForm()
Returns { step, total, direction, isFirst, isLast, goTo, next, back, reducedMotion } for use in a custom control bar or progress indicator.
How it works
- Three progress styles:
barprogress bar,dots(the current dot stretches into a pill), andstepsnumbered steps (completed ones get a checkmark and the connecting lines colour in with the progress). - Direction awareness: going forward, the new step slides in from the right and the old one exits to the left; going back is the reverse. The direction is derived during render from the previous index, and is correct in both controlled and uncontrolled modes.
- The viewport uses
overflow-hiddento clip the horizontal slide, andpopLayouttakes the exiting step out of flow so the entering step decides the container height, preventing the height from collapsing during the transition.
Accessibility
- Steps in the progress indicator that are current or already visited can be clicked to jump back (
goTo), while steps not yet reached are disabled, preserving the fill-in order. - The current step is marked
aria-current="step"; the progress bar providesrole="progressbar"andaria-valuenow. - Step changes are announced as "step x of y" through an
aria-live="polite"region. - The built-in control bar uses native
buttons that are keyboard-operable with afocus-visiblering; the back button is disabled on the first step. - When the user has "reduce motion" enabled at the system level, the slide degrades to a brief fade in / fade out, and the progress fill jumps straight into position.
FAQ
Does it work with the Next.js App Router?
Yes. The file already starts with "use client", so import it from a Server Component page as usual; any wrapper that holds state or event handlers needs its own "use client" too.
Can I use it with a form library such as React Hook Form?
Yes. The component does not manage field values, so wrap the whole MultiStepForm in your <form> or form provider and switch steps after validation, either in controlled mode or by calling next() from useMultiStepForm(). The built-in control buttons are type="button", so they never submit the form by accident.
Why are other elements inside MultiStepForm not rendered?
The container only picks up direct MultiStepFormStep children and ignores everything else. Put headings and helper text inside a step's content or outside MultiStepForm.
Do screen readers hear the step change?
Yes. An aria-live="polite" region announces "step x / y" with the step title, and the current step carries aria-current="step". With the OS "reduce motion" setting on, the slide becomes a short fade.
What are the dependencies?
motion, lucide-react, clsx and tailwind-merge; see the installation section above.
File Dropzone
A drag-and-drop upload area — the magnetic dashed border leans toward the cursor and marches like a marquee, and the file list carries ring progress indicators.
Inline Edit Field
A click-to-edit inline field — the display and edit states swap seamlessly with a layout morph, Enter/blur submits and Esc cancels, with multiline support, controlled and uncontrolled modes, and no accidental submit mid-IME-composition.