"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/* -------------------------------------------------------------------------- */
/* Public types */
/* -------------------------------------------------------------------------- */
export type MultiStepFormValues = Record<string, string>;
export type MultiStepFieldType = "text" | "email" | "tel" | "url" | "number" | "textarea" | "select" | "radio";
export interface MultiStepFieldOption {
label: string;
value: string;
/** Secondary line, shown on radio cards. */
description?: string;
}
export interface MultiStepField {
/** Key in the submitted values; also the input `name`. */
name: string;
label: string;
type?: MultiStepFieldType;
required?: boolean;
placeholder?: string;
hint?: string;
autoComplete?: string;
/** Options for `select` and `radio` fields. */
options?: MultiStepFieldOption[];
/** Custom validation. Return an error message, or nothing when valid. */
validate?: (value: string, values: MultiStepFormValues) => string | null | undefined;
}
export interface MultiStepFormStep {
id: string;
title: string;
description?: string;
fields: MultiStepField[];
}
export interface MultiStepFormProps extends Omit<React.ComponentPropsWithoutRef<"form">, "onSubmit" | "children"> {
steps: MultiStepFormStep[];
/** Called with all values after the last step validates. May return a promise. */
onSubmit?: (values: MultiStepFormValues) => void | Promise<void>;
initialValues?: MultiStepFormValues;
/** Called whenever the visible step changes. */
onStepChange?: (index: number, step: MultiStepFormStep) => void;
/** Accent colour (any CSS colour). */
accent?: string;
/** Step transition style. */
transition?: "slide" | "fade";
/** Progress indicator style. */
progress?: "steps" | "bar";
/** Step transition duration in ms. */
duration?: number;
nextLabel?: string;
backLabel?: string;
submitLabel?: string;
successTitle?: string;
successMessage?: string;
}
/* -------------------------------------------------------------------------- */
/* Validation */
/* -------------------------------------------------------------------------- */
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function validateField(field: MultiStepField, values: MultiStepFormValues): string | undefined {
const raw = values[field.name] ?? "";
const value = raw.trim();
const choice = field.type === "radio" || field.type === "select";
if (field.required && !value) return choice ? `Choose ${field.label.toLowerCase()}.` : `${field.label} is required.`;
if (value && field.type === "email" && !EMAIL.test(value)) return "Enter a valid email address.";
if (value && field.type === "url") {
try {
new URL(value);
} catch {
return "Enter a full URL, including https://.";
}
}
if (value && field.type === "number" && !Number.isFinite(Number(value))) return "Enter a number.";
return field.validate?.(raw, values) ?? undefined;
}
function validateStep(step: MultiStepFormStep, values: MultiStepFormValues) {
const errors: Record<string, string> = {};
for (const field of step.fields) {
const error = validateField(field, values);
if (error) errors[field.name] = error;
}
return errors;
}
/* -------------------------------------------------------------------------- */
/* Fields */
/* -------------------------------------------------------------------------- */
const controlClass =
"block w-full rounded-lg border bg-white px-3.5 text-[15px] outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-zinc-400 dark:bg-zinc-950 dark:placeholder:text-zinc-500 motion-reduce:transition-none";
const textClass = "text-zinc-950 dark:text-zinc-50";
interface FieldProps {
field: MultiStepField;
id: string;
value: string;
error?: string;
onChange: (name: string, value: string) => void;
}
function Field({ field, id, value, error, onChange }: FieldProps) {
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy = [field.hint ? hintId : null, error ? errorId : null].filter(Boolean).join(" ") || undefined;
const border = error
? "border-rose-500/80 focus:border-rose-500 focus:shadow-[0_0_0_3px_rgba(244,63,94,0.18)] dark:border-rose-400/80 dark:focus:border-rose-400"
: "border-zinc-900/15 hover:border-zinc-900/30 focus:border-[var(--msf-accent)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--msf-accent)_22%,transparent)] dark:border-white/15 dark:hover:border-white/30";
const type = field.type ?? "text";
const shared = {
id,
name: field.name,
required: field.required,
"aria-invalid": error ? true : undefined,
"aria-describedby": describedBy,
};
const labelEl = (
<span className="flex items-baseline justify-between gap-3">
<span className="text-sm font-medium tracking-tight text-zinc-900 dark:text-zinc-100">{field.label}</span>
{!field.required && (
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500">Optional</span>
)}
</span>
);
const messages = (
<>
{field.hint && (
<p id={hintId} className="mt-1.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400">
{field.hint}
</p>
)}
{error && (
<p id={errorId} className="mt-1.5 text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400">
{error}
</p>
)}
</>
);
if (type === "radio") {
const labelId = `${id}-label`;
return (
<div>
<div id={labelId} className="mb-2">
{labelEl}
</div>
<div
role="radiogroup"
aria-labelledby={labelId}
aria-required={field.required || undefined}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className="grid gap-2 sm:grid-cols-2"
>
{(field.options ?? []).map((option, i) => {
const checked = value === option.value;
return (
<label
key={option.value}
className={`relative flex cursor-pointer flex-col rounded-lg border px-3.5 py-3 transition-[border-color,background-color,box-shadow] duration-150 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_srgb,var(--msf-accent)_30%,transparent)] motion-reduce:transition-none ${
checked
? "border-[var(--msf-accent)] bg-[color-mix(in_srgb,var(--msf-accent)_8%,transparent)]"
: `${error ? "border-rose-500/60 dark:border-rose-400/60" : "border-zinc-900/15 dark:border-white/15"} bg-white hover:border-zinc-900/30 dark:bg-zinc-950 dark:hover:border-white/30`
}`}
>
<input
type="radio"
// The first radio carries the field id so focusing "the field" lands in the group.
id={i === 0 ? id : undefined}
name={field.name}
value={option.value}
checked={checked}
onChange={() => onChange(field.name, option.value)}
className="sr-only"
/>
<span className="flex items-center justify-between gap-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">
{option.label}
<span
aria-hidden="true"
className={`grid size-4 place-items-center rounded-full border transition-colors ${
checked ? "border-[var(--msf-accent)] bg-[var(--msf-accent)]" : "border-zinc-900/25 dark:border-white/25"
}`}
>
{checked && <span className="size-1.5 rounded-full bg-white" />}
</span>
</span>
{option.description && (
<span className="mt-0.5 text-[13px] text-zinc-500 dark:text-zinc-400">{option.description}</span>
)}
</label>
);
})}
</div>
{messages}
</div>
);
}
return (
<div>
<label htmlFor={id} className="mb-2 block">
{labelEl}
</label>
{type === "textarea" ? (
<textarea
{...shared}
rows={4}
placeholder={field.placeholder}
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
className={`${controlClass} ${textClass} ${border} min-h-28 resize-y py-2.5`}
/>
) : type === "select" ? (
<div className="relative">
<select
{...shared}
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
className={`${controlClass} ${border} h-11 appearance-none pr-10 ${value ? textClass : "text-zinc-400 dark:text-zinc-500"}`}
>
<option value="" disabled>
{field.placeholder ?? "Select…"}
</option>
{(field.options ?? []).map((o) => (
<option key={o.value} value={o.value} className="text-zinc-950">
{o.label}
</option>
))}
</select>
<svg aria-hidden="true" viewBox="0 0 16 16" className="pointer-events-none absolute right-3.5 top-1/2 size-4 -translate-y-1/2 text-zinc-400" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="m4 6 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
) : (
<input
{...shared}
type={type}
inputMode={type === "number" ? "decimal" : undefined}
autoComplete={field.autoComplete}
placeholder={field.placeholder}
value={value}
onChange={(e) => onChange(field.name, e.target.value)}
className={`${controlClass} ${textClass} ${border} h-11`}
/>
)}
{messages}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Form */
/* -------------------------------------------------------------------------- */
/**
* A multi-stage form driven by a typed `steps` config. Each step validates before advancing,
* transitions are direction-aware, focus moves to the new step's heading (or the first invalid
* field), and the final step resolves into a success state.
*/
export function MultiStepForm({
steps,
onSubmit,
initialValues,
onStepChange,
accent = "#FF7448",
transition = "slide",
progress = "steps",
duration = 320,
nextLabel = "Continue",
backLabel = "Back",
submitLabel = "Send enquiry",
successTitle = "Thanks — we'll be in touch.",
successMessage = "Your details are safely with us. Expect a reply within two working days.",
className = "",
style,
...props
}: MultiStepFormProps) {
const reduceMotion = useReducedMotion();
const uid = React.useId();
const formRef = React.useRef<HTMLFormElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const focusPending = React.useRef(false);
const [index, setIndex] = React.useState(0);
const [direction, setDirection] = React.useState(1);
const [values, setValues] = React.useState<MultiStepFormValues>(() => ({ ...initialValues }));
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [status, setStatus] = React.useState<"editing" | "submitting" | "success">("editing");
const [submitError, setSubmitError] = React.useState<string | null>(null);
const [height, setHeight] = React.useState<number | "auto">("auto");
const total = steps.length;
const step = steps[Math.min(index, total - 1)];
const isLast = index === total - 1;
const done = status === "success";
const fieldId = (name: string) => `${uid}-${name}`;
// Measure the active panel so the frame can animate between step heights.
React.useEffect(() => {
const el = contentRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, []);
/** Focuses a step heading once it mounts — only after user navigation, never on first render. */
const headingRef = React.useCallback((el: HTMLHeadingElement | null) => {
if (el && focusPending.current) {
focusPending.current = false;
el.focus({ preventScroll: true });
}
}, []);
const goTo = (next: number) => {
if (next === index || next < 0 || next >= total) return;
setDirection(next > index ? 1 : -1);
setIndex(next);
setSubmitError(null);
focusPending.current = true;
onStepChange?.(next, steps[next]);
};
const handleChange = (name: string, value: string) => {
const nextValues = { ...values, [name]: value };
setValues(nextValues);
// Once a field shows an error, re-validate it live so the message clears as soon as it's fixed.
if (errors[name]) {
const field = step.fields.find((f) => f.name === name);
const error = field ? validateField(field, nextValues) : undefined;
setErrors((prev) => {
const copy = { ...prev };
if (error) copy[name] = error;
else delete copy[name];
return copy;
});
}
};
const focusFirstInvalid = (stepErrors: Record<string, string>) => {
const first = step.fields.find((f) => stepErrors[f.name]);
if (!first) return;
formRef.current?.ownerDocument.getElementById(fieldId(first.name))?.focus();
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (status !== "editing") return;
const stepErrors = validateStep(step, values);
setErrors(stepErrors);
if (Object.keys(stepErrors).length) {
focusFirstInvalid(stepErrors);
return;
}
if (!isLast) {
goTo(index + 1);
return;
}
setStatus("submitting");
setSubmitError(null);
try {
await onSubmit?.(values);
focusPending.current = true;
setDirection(1);
setStatus("success");
} catch {
setStatus("editing");
setSubmitError("Something went wrong while sending. Please try again.");
}
};
const restart = () => {
setValues({ ...initialValues });
setErrors({});
setDirection(-1);
setIndex(0);
setStatus("editing");
focusPending.current = true;
onStepChange?.(0, steps[0]);
};
const seconds = reduceMotion ? 0 : Math.max(0, duration) / 1000;
const offset = transition === "slide" && !reduceMotion ? 28 : 0;
const variants = {
enter: (dir: number) => ({ opacity: 0, x: dir * offset }),
center: { opacity: 1, x: 0 },
exit: (dir: number) => ({ opacity: 0, x: dir * -offset }),
};
const ease = [0.22, 0.75, 0.2, 1] as const;
const progressValue = done ? 1 : (index + 1) / total;
const statusText = done ? "Complete" : `Step ${index + 1} of ${total}`;
return (
<form
ref={formRef}
noValidate
onSubmit={handleSubmit}
aria-busy={status === "submitting" || undefined}
className={`w-full rounded-2xl border border-zinc-900/10 bg-white/90 p-5 sm:p-7 dark:border-white/10 dark:bg-zinc-950/80 ${className}`}
style={{ ["--msf-accent" as string]: accent, ...style }}
{...props}
>
{/* Progress */}
<div className="mb-6">
<div className="flex items-center justify-between font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
<span aria-live="polite" aria-atomic="true">
{statusText}
<span className="sr-only">{done ? "" : `: ${step.title}`}</span>
</span>
<span aria-hidden="true" className="tabular-nums">
{String(Math.round(progressValue * 100)).padStart(2, "0")}%
</span>
</div>
{progress === "bar" ? (
<div aria-hidden="true" className="mt-3 h-[3px] overflow-hidden rounded-full bg-zinc-900/[0.07] dark:bg-white/10">
<motion.div
className="h-full origin-left rounded-full bg-[var(--msf-accent)]"
initial={false}
animate={{ scaleX: progressValue }}
transition={{ duration: seconds * 1.4, ease }}
/>
</div>
) : (
<ol className="mt-3 flex gap-2">
{steps.map((s, i) => {
const state = done || i < index ? "complete" : i === index ? "current" : "upcoming";
const content = (
<>
<span className="relative block h-[3px] w-full overflow-hidden rounded-full bg-zinc-900/[0.07] dark:bg-white/10">
<motion.span
className="absolute inset-0 origin-left rounded-full bg-[var(--msf-accent)]"
initial={false}
animate={{ scaleX: state === "upcoming" ? 0 : 1 }}
transition={{ duration: seconds * 1.2, ease }}
/>
</span>
<span
className={`mt-2 block truncate text-left text-[13px] tracking-tight ${
state === "upcoming" ? "text-zinc-400 dark:text-zinc-500" : "font-medium text-zinc-900 dark:text-zinc-100"
}`}
>
<span className="mr-1.5 font-mono text-[10px] text-zinc-400 dark:text-zinc-500">{String(i + 1).padStart(2, "0")}</span>
{s.title}
</span>
</>
);
return (
<li key={s.id} className="min-w-0 flex-1" aria-current={state === "current" ? "step" : undefined}>
{state === "complete" && !done && status === "editing" ? (
// Completed steps can be revisited.
<button
type="button"
onClick={() => goTo(i)}
className="block w-full rounded focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--msf-accent)]"
>
<span className="sr-only">Go back to step {i + 1}: </span>
{content}
<span className="sr-only"> (completed)</span>
</button>
) : (
<div>
{content}
{state === "complete" && <span className="sr-only"> (completed)</span>}
</div>
)}
</li>
);
})}
</ol>
)}
</div>
{/* Animated frame: height follows the active panel. */}
<motion.div
className="-m-1 overflow-hidden"
initial={false}
animate={{ height }}
transition={{ duration: seconds, ease }}
>
<div ref={contentRef} className="p-1">
<AnimatePresence mode="wait" initial={false} custom={direction}>
{done ? (
<motion.div
key="success"
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: seconds, ease }}
className="flex flex-col items-start py-2"
>
<span className="grid size-10 place-items-center rounded-full bg-[color-mix(in_srgb,var(--msf-accent)_16%,transparent)] text-[var(--msf-accent)]">
<svg aria-hidden="true" viewBox="0 0 20 20" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<motion.path
d="m5 10.5 3.2 3L15 6.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: reduceMotion ? 0 : 0.45, delay: seconds, ease }}
/>
</svg>
</span>
<h3 ref={headingRef} tabIndex={-1} className="mt-4 text-xl font-medium tracking-tight text-zinc-950 outline-none dark:text-white">
{successTitle}
</h3>
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{successMessage}</p>
<button
type="button"
onClick={restart}
className="mt-5 text-sm font-medium text-zinc-900 underline decoration-zinc-900/30 underline-offset-4 hover:decoration-zinc-900 focus-visible:rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--msf-accent)] dark:text-zinc-100 dark:decoration-white/30 dark:hover:decoration-white"
>
Start over
</button>
</motion.div>
) : (
<motion.fieldset
key={step.id}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: seconds, ease }}
disabled={status === "submitting"}
className="min-w-0"
aria-labelledby={`${uid}-${step.id}-title`}
>
<h3
id={`${uid}-${step.id}-title`}
ref={headingRef}
tabIndex={-1}
className="text-xl font-medium tracking-tight text-zinc-950 outline-none dark:text-white"
>
{step.title}
</h3>
{step.description && <p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{step.description}</p>}
<div className="mt-5 flex flex-col gap-5">
{step.fields.map((field) => (
<Field
key={field.name}
field={field}
id={fieldId(field.name)}
value={values[field.name] ?? ""}
error={errors[field.name]}
onChange={handleChange}
/>
))}
</div>
</motion.fieldset>
)}
</AnimatePresence>
</div>
</motion.div>
{submitError && (
<p role="alert" className="mt-4 text-[13px] font-medium text-rose-600 dark:text-rose-400">
{submitError}
</p>
)}
{!done && (
<div className="mt-7 flex items-center justify-between gap-3 border-t border-zinc-900/[0.07] pt-5 dark:border-white/10">
<button
type="button"
onClick={() => goTo(index - 1)}
disabled={index === 0 || status === "submitting"}
className="inline-flex h-10 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-900/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--msf-accent)] disabled:pointer-events-none disabled:opacity-0 dark:text-zinc-300 dark:hover:bg-white/5"
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="M10 3.5 5.5 8l4.5 4.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{backLabel}
</button>
<button
type="submit"
disabled={status === "submitting"}
className="relative inline-flex h-10 items-center gap-1.5 rounded-lg bg-zinc-950 px-4 text-sm font-medium text-white transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--msf-accent)] disabled:cursor-wait disabled:opacity-70 dark:bg-white dark:text-zinc-950"
>
{status === "submitting" ? (
<>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4 animate-spin motion-reduce:animate-none" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M14 8a6 6 0 1 1-6-6" strokeLinecap="round" />
</svg>
Sending…
</>
) : (
<>
{isLast ? submitLabel : nextLabel}
<span aria-hidden="true" className="inline-block size-1.5 rounded-full bg-[var(--msf-accent)]" />
</>
)}
</button>
</div>
)}
</form>
);
}