Forms
MultiStepForm
One form split into steps. A rail fills as you go, steps slide in the direction you move, and each step must pass its normal field rules before the next opens.
Download
- React
- Tailwind CSS v4
- motion
components/ui/multi-step-form.tsx
"use client";
import { useEffect, useRef, useState, type CSSProperties, type FormEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
export type MultiStepFormStep = {
title: string;
/** Small text under the title. */
description?: string;
/** The fields of this step. Give each a `name` and use normal rules like `required`. */
content: ReactNode;
};
type MultiStepFormProps = {
steps: MultiStepFormStep[];
/** Called with every field from every step after the last step passes. */
onSubmit?: (data: FormData) => void;
/** Label of the last button. */
submitLabel?: string;
/** Shown once the form is sent. */
success?: ReactNode;
/** Rail and button colour. */
accent?: string;
/** Wrapper look (width etc.). */
className?: string;
};
const FIELDS = "input:not([type=hidden]),select,textarea";
/* MultiStepForm: one form split into steps. A rail fills as you go, steps slide in the direction you move, and each step must pass its normal field rules before the next opens. Every step stays mounted, so typed values are kept when going back. */
export function MultiStepForm({
steps,
onSubmit,
submitLabel = "Submit",
success = "All done. Thank you!",
accent = "#a78bfa",
className = "w-full max-w-md",
}: MultiStepFormProps) {
const reduce = useReducedMotion();
const panels = useRef<(HTMLDivElement | null)[]>([]);
const moved = useRef(false);
const doneBox = useRef<HTMLDivElement>(null);
const [step, setStep] = useState(0);
const [done, setDone] = useState(false);
const last = step === steps.length - 1;
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 340, damping: 34 };
useEffect(() => {
if (moved.current) panels.current[step]?.querySelector<HTMLElement>(FIELDS)?.focus({ preventScroll: true });
}, [step]);
useEffect(() => {
if (done) doneBox.current?.focus();
}, [done]);
const go = (to: number) => {
moved.current = true;
setStep(to);
};
const submit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fields = panels.current[step]?.querySelectorAll<HTMLInputElement>(FIELDS) ?? [];
if (!Array.from(fields).every((f) => f.reportValidity())) return;
if (!last) return go(step + 1);
onSubmit?.(new FormData(e.currentTarget));
setDone(true);
};
return (
<div className={className} style={{ "--mf": accent } as CSSProperties}>
{done ? (
<div ref={doneBox} tabIndex={-1} role="status" className="grid justify-items-center gap-3 py-8 text-center outline-none">
<motion.span
className="grid size-14 place-items-center rounded-full bg-[var(--mf)]"
initial={reduce ? false : { scale: 0.3, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 20 }}
>
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="#0b0a14" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="size-7">
<motion.path
d="m5 12.5 4.5 4.5L19 7.5"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: reduce ? 0 : 0.35, delay: reduce ? 0 : 0.15, ease: "easeOut" }}
/>
</svg>
</motion.span>
<div className="text-base font-medium text-white">{success}</div>
</div>
) : (
<form noValidate onSubmit={submit}>
<ol className="relative mb-5 flex justify-between">
<span aria-hidden className="absolute inset-x-4 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-white/10">
<motion.span
className="block size-full origin-left rounded-full bg-[var(--mf)]"
initial={false}
animate={{ scaleX: step / Math.max(steps.length - 1, 1) }}
transition={spring}
/>
</span>
{steps.map((s, i) => (
<li key={s.title} className="relative">
<button
type="button"
disabled={i >= step}
aria-current={i === step ? "step" : undefined}
aria-label={`Step ${i + 1}: ${s.title}`}
onClick={() => go(i)}
className={`grid size-8 place-items-center rounded-full border text-sm font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[var(--mf)] motion-reduce:transition-none ${
i < step
? "cursor-pointer border-[var(--mf)] bg-[var(--mf)] text-[#0b0a14]"
: i === step
? "border-[var(--mf)] bg-[#0b0a14] text-white"
: "border-white/15 bg-[#0b0a14] text-white/40"
}`}
>
{i < step ? (
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="size-4">
<path d="m5 12.5 4.5 4.5L19 7.5" />
</svg>
) : (
i + 1
)}
</button>
</li>
))}
</ol>
<div className="grid overflow-hidden">
{steps.map((s, i) => {
const here = i === step;
return (
<motion.div
key={s.title}
ref={(el) => {
panels.current[i] = el;
}}
inert={!here}
initial={false}
animate={{ x: reduce ? 0 : (i - step) * 48, opacity: here ? 1 : 0 }}
transition={spring}
className={`min-w-0 p-0.5 [grid-area:1/1] ${here ? "visible" : "invisible delay-300 motion-reduce:delay-0"} transition-[visibility] duration-0`}
>
<h3 className="text-lg font-semibold text-white">{s.title}</h3>
{s.description && <p className="mt-0.5 text-sm text-white/55">{s.description}</p>}
<div className="mt-4 grid gap-3">{s.content}</div>
</motion.div>
);
})}
</div>
<div className="mt-5 flex items-center justify-between gap-3">
<button
type="button"
onClick={() => go(step - 1)}
className={`h-11 rounded-xl px-4 text-[15px] font-medium text-white/70 outline-none transition-colors duration-150 hover:text-white focus-visible:ring-2 focus-visible:ring-[var(--mf)] motion-reduce:transition-none ${step === 0 ? "invisible" : ""}`}
>
Back
</button>
<p aria-live="polite" className="text-xs text-white/45">
Step {step + 1} of {steps.length}
</p>
<button
type="submit"
className="h-11 rounded-xl bg-[var(--mf)] px-5 text-[15px] font-semibold text-[#0b0a14] outline-none transition-transform duration-100 active:scale-[0.97] focus-visible:ring-2 focus-visible:ring-[var(--mf)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b0a14] motion-reduce:transition-none"
>
{last ? submitLabel : "Next"}
</button>
</div>
</form>
)}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/multi-step-form.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/multi-step-form-demo.tsx
"use client"; import { CheckboxBloom } from "@/components/ui/checkbox-bloom"; import { FloatInput } from "@/components/ui/float-input"; import { MultiStepForm } from "@/components/ui/multi-step-form"; import { RadioSphere } from "@/components/ui/radio-sphere"; const PLANS = [ { value: "starter", label: "Starter", hint: "Free" }, { value: "pro", label: "Pro", hint: "โน499 a month" }, ]; const STEPS = [ { title: "Your details", description: "So we know who to write to.", content: ( <> <FloatInput label="Name" name="name" required className="w-full" /> <FloatInput label="Email" name="email" type="email" required className="w-full" /> </> ), }, { title: "Pick a plan", description: "Change it any time.", content: <RadioSphere label="Plan" name="plan" options={PLANS} defaultValue="pro" className="w-full" />, }, { title: "Last check", description: "One tick and you are in.", content: <CheckboxBloom label="I accept the terms" name="terms" required className="w-full" />, }, ]; export default function MultiStepFormExample() { return ( <div className="flex h-full justify-center overflow-y-auto bg-[#0b0a14] p-5"> <MultiStepForm steps={STEPS} submitLabel="Create account" success="Account created. Welcome in!" className="my-auto w-full max-w-md" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| steps | { title, description?, content }[] | โ | The steps. Give fields a name and normal rules like required. |
| onSubmit | (data: FormData) => void | โ | Called with every field from every step. |
| submitLabel | string | "Submit" | Label of the last button. |
| success | ReactNode | "All done. Thank you!" | Shown once the form is sent. |
| accent | string | "#a78bfa" | Rail and button colour. |
| className | string | "w-full max-w-md" | Wrapper look. |