Steps form
A multi-step form in one card that changes shape: steps slide in from the way you’re going, checks shake and explain, and Create turns into what comes next.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/steps-form.jsonOverview
Onboarding, checkout, setup: most multi-step forms jump between pages and lose you in between. This one is a single card. A bar of segments fills as you go, the step’s name and title morph letter by letter and "2 / 4" rolls. The next step slides in from the side you’re heading, out of a blur, while the card eases to its new height; going back slides the other way. Continue runs the step’s check, and if something’s wrong the step shakes and the reason folds open under it (an async check, like whether a name is free, shows "Checking" in the button first). Back folds out of the footer on the first step. On the last step Continue morphs into your action, spins, draws a check, and the whole card turns into whatever comes next.
States
- header
- Segments fill left to right as scaleX (520ms); the label and title morph; the counter rolls.
- step
- The new step slides 28px in from the direction you’re going out of a 4px blur (420ms) while the old one slides out the other way (260ms); the card’s height eases (440ms). Focus moves to the new step’s first field.
- check
- validate returns a message: the step shakes (±6px, 380ms) and the message folds open under it with an alert icon. A promise shows the button’s spinner and "Checking" until it settles.
- back
- Hidden on the first step by folding its column to nothing (360ms), so Continue never jumps.
- submit
- On the last step the button reads your label; onSubmit runs with Status button’s spinner, then a drawn check, and 750ms later the header and footer fold away and done replaces the step. A thrown error shows its message and "Try again".
Usage
import { StepsForm } from "@/components/steps-form";
<StepsForm
steps={[
{ id: "name", label: "Workspace", title: "Name your workspace", content: <NameField />, validate: () => (name ? null : "Give it a name.") },
{ id: "plan", label: "Plan", title: "Pick a plan", content: <Plans /> },
]}
onSubmit={() => createWorkspace({ name, plan })}
submitLabels={{ idle: "Create workspace", pending: "Creating", success: "Created" }}
done={<Ready />}
/>Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
Works with
Installs with it
- Number rollNumbers that change like an odometer: every digit rolls in the direction the number moved.
- Status buttonSave, Saving, Saved: the spinner opens in, the check draws itself, errors shake, and the label morphs between them.
- Text morphText that changes the way it should: shared letters slide over, new ones rise out of a blur, the width eases.
Checks against your API
A step’s validate can be async: return a message to stay, nothing to move on. onSubmit throwing an Error keeps the person on the last step with its message.
"use client";
import { useState } from "react";
import { StepsForm } from "@/components/steps-form";
export function CreateWorkspace() {
const [name, setName] = useState("");
return (
<StepsForm
steps={[
{
id: "name",
label: "Workspace",
title: "Name your workspace",
content: <input value={name} onChange={(e) => setName(e.target.value)} aria-label="Workspace name" />,
validate: async () => {
if (name.trim().length < 2) return "Give it a name, two letters or more.";
const res = await fetch(`/api/workspaces/available?name=${encodeURIComponent(name)}`);
if (!(await res.json()).available) return "That name is taken. Try another.";
},
},
// …more steps
]}
onSubmit={async () => {
const res = await fetch("/api/workspaces", { method: "POST", body: JSON.stringify({ name }) });
if (!res.ok) throw new Error("We couldn’t create it just now. Try again.");
}}
submitLabels={{ idle: "Create workspace", pending: "Creating", success: "Created" }}
done={<p>{name} is ready.</p>}
/>
);
}Props
| Prop | Type | Description |
|---|---|---|
| steps | { id, label, title, description?, content, validate? }[] | In order. You own the fields’ state; content is whatever the step shows. |
| onSubmit | () => void | Promise<void> | Runs after the last step’s check. Throw to stay on the last step; the error’s message is shown. |
| submitLabels | { idle?, pending?, success? }default "Create", "Creating", "Created" | The last button’s labels as it runs. |
| done | ReactNode | What the card turns into once it’s submitted. |
| onStepChange | (index: number) => void | Runs when the step changes, e.g. for analytics. |
Accessibility and motion
- It’s a form: Enter in a field continues, the title labels it, and errors are announced with role="alert".
- Focus moves to each new step’s first field (or an element marked data-autofocus), but not on first load.
- Steps that aren’t showing are unmounted, so keep their values in your own state, as the demo does.
- Installs Number roll, Status button and Text morph. With reduced motion steps swap in place, nothing shakes, and the card resizes instantly.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
The CLI also installs Number roll, Status button, Text morph.
Source
›Show the full source of steps-form.tsx (296 lines)
"use client";
import * as React from "react";
import { AlertCircle } from "lucide-react";
import { NumberRoll } from "./number-roll";
import { StatusButton, type ActionStatus } from "./status-button";
import { TextMorph } from "./text-morph";
/* ─────────────────────────────────────────────────────────
* STEPS FORM: one card that walks you through, and changes shape
*
* header a bar of segments fills as you go; the step's name
* and title morph letter by letter and "2 / 4" rolls
* step the next step slides in from the side you're going
* (and back from the other side), out of a blur, while
* the card eases to its height
* check Continue runs the step's check: a message shakes the
* step and folds open under it; an async check shows
* "Checking" in the button first
* back Back folds out of the footer on the first step
* submit on the last step Continue morphs into your action;
* it spins, draws a check, and the whole card turns into
* what comes next
*
* It's a real form: Enter continues, and focus moves to the new
* step's first field.
* ───────────────────────────────────────────────────────── */
export interface FormStep {
id: string;
/** Short name for the header, e.g. "Plan". */
label: string;
title: string;
description?: React.ReactNode;
content: React.ReactNode;
/** Return a message to stop on this step, or nothing to go on. */
validate?: () => string | null | undefined | void | Promise<string | null | undefined | void>;
}
export interface StepsFormProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit" | "children"> {
steps: FormStep[];
/** Runs after the last step's check; throw (with a message) to stay. */
onSubmit: () => void | Promise<void>;
/** The last button's labels: idle, while it runs, and when it's done. */
submitLabels?: Partial<Record<"idle" | "pending" | "success", string>>;
/** What the card becomes once it's submitted. */
done?: React.ReactNode;
onStepChange?: (index: number) => void;
}
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
/** The part that changes: eases to each step's height, slides the new one in over the old one leaving. */
function Panel({ id, dir, children, reduced }: { id: string; dir: number; children: React.ReactNode; reduced: boolean }) {
const innerRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number | null>(null);
const [ghost, setGhost] = React.useState<{ id: string; node: React.ReactNode; dir: number } | null>(null);
const last = React.useRef({ id, node: children });
React.useLayoutEffect(() => {
const el = innerRef.current;
if (!el) return;
const measure = () => setHeight(el.offsetHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [id]);
React.useLayoutEffect(() => {
if (id === last.current.id) {
last.current.node = children;
return;
}
if (!reduced) setGhost({ id: last.current.id, node: last.current.node, dir });
last.current = { id, node: children };
if (reduced) return;
innerRef.current?.animate(
[
{ opacity: 0, transform: `translateX(${dir * 28}px)`, filter: "blur(4px)" },
{ opacity: 1, transform: "none", filter: "blur(0px)" },
],
{ duration: 420, delay: 60, easing: EASE, fill: "backwards" },
);
}, [id, children, dir, reduced]);
return (
<div className="relative overflow-hidden" style={{ height: height ?? undefined, transition: reduced || height === null ? "none" : `height 440ms ${EASE}` }}>
{ghost && (
<div
key={`ghost-${ghost.id}`}
aria-hidden="true"
inert
className="pointer-events-none absolute inset-x-0 top-0"
ref={(el) => {
if (!el || el.dataset.out) return;
el.dataset.out = "1";
const a = el.animate(
[
{ opacity: 1, transform: "none", filter: "blur(0px)" },
{ opacity: 0, transform: `translateX(${ghost.dir * -28}px)`, filter: "blur(4px)" },
],
{ duration: 260, easing: EASE, fill: "forwards" },
);
a.onfinish = () => setGhost((g) => (g?.id === ghost.id ? null : g));
}}
>
{ghost.node}
</div>
)}
<div key={id} ref={innerRef}>
{children}
</div>
</div>
);
}
export function StepsForm({
steps,
onSubmit,
submitLabels,
done,
onStepChange,
className = "",
...props
}: StepsFormProps) {
const reduced = useReducedMotion();
const id = React.useId();
const formRef = React.useRef<HTMLFormElement>(null);
const bodyRef = React.useRef<HTMLDivElement>(null);
const [index, setIndex] = React.useState(0);
const [dir, setDir] = React.useState(1);
const [status, setStatus] = React.useState<ActionStatus>("idle");
const [error, setError] = React.useState<string | null>(null);
const [finished, setFinished] = React.useState(false);
const [moved, setMoved] = React.useState(false);
const step = steps[index];
const last = index === steps.length - 1;
const working = status === "pending" || status === "success";
const go = (next: number) => {
setDir(next > index ? 1 : -1);
setIndex(next);
setError(null);
setStatus("idle");
setMoved(true);
onStepChange?.(next);
};
// A new step: put the cursor in its first field (not on first load, which shouldn't steal focus).
React.useEffect(() => {
if (!moved) return;
const field = bodyRef.current?.querySelector<HTMLElement>("[data-autofocus], input:not([type=hidden]), select, textarea");
field?.focus({ preventScroll: true });
}, [index, moved]);
const shake = () => {
if (reduced) return;
bodyRef.current?.animate(
[{ transform: "none" }, { transform: "translateX(-6px)" }, { transform: "translateX(5px)" }, { transform: "translateX(-3px)" }, { transform: "translateX(2px)" }, { transform: "none" }],
{ duration: 380, easing: "ease-out" },
);
};
const next = async () => {
if (working) return;
setError(null);
const check = step.validate?.();
if (check instanceof Promise) setStatus("pending");
const message = await check;
if (message) {
setStatus("idle");
setError(message);
shake();
return;
}
if (!last) return go(index + 1);
setStatus("pending");
try {
await onSubmit();
setStatus("success");
window.setTimeout(() => setFinished(true), reduced ? 0 : 750);
} catch (e) {
setStatus("error");
setError(e instanceof Error && e.message ? e.message : "That didn’t work. Try again.");
shake();
}
};
const labels = last
? { idle: submitLabels?.idle ?? "Create", pending: submitLabels?.pending ?? "Creating", success: submitLabels?.success ?? "Created", error: "Try again" }
: { idle: "Continue", pending: "Checking", success: "Continue", error: "Continue" };
return (
<form
ref={formRef}
noValidate
aria-labelledby={`${id}-title`}
onSubmit={(e) => {
e.preventDefault();
void next();
}}
// Once they start fixing it, the message folds away.
onInput={() => error && status !== "error" && setError(null)}
onChange={() => error && status !== "error" && setError(null)}
className={`@container w-full rounded-[20px] border border-border bg-card text-card-foreground ${className}`}
{...props}
>
{/* Header: folds away once it's done. */}
<div className="grid" style={{ gridTemplateRows: finished ? "0fr" : "1fr", transition: reduced ? "none" : `grid-template-rows 420ms ${EASE}` }}>
<div className="overflow-hidden">
<div className="px-4 pt-4 @md:px-5 @md:pt-5">
<div className="flex items-center justify-between text-[12px] text-muted-foreground">
<TextMorph>{step.label}</TextMorph>
<span className="tabular-nums" aria-hidden="true">
<NumberRoll value={index + 1} duration={450} /> / {steps.length}
</span>
</div>
<div className="mt-2 flex gap-1" aria-hidden="true">
{steps.map((s, i) => (
<span key={s.id} className="h-1 flex-1 overflow-hidden rounded-full bg-muted">
<span
className="block h-full origin-left rounded-full bg-primary"
style={{
transform: `scaleX(${i <= index || finished ? 1 : 0})`,
transition: reduced ? "none" : `transform 520ms ${EASE} ${i === index && dir > 0 ? 80 : 0}ms`,
}}
/>
</span>
))}
</div>
<h2 id={`${id}-title`} className="mt-4 text-[18px] font-semibold leading-tight tracking-tight text-foreground @md:text-[20px]">
<TextMorph>{step.title}</TextMorph>
</h2>
</div>
</div>
</div>
<div ref={bodyRef} className="px-4 @md:px-5">
<Panel id={finished ? "done" : step.id} dir={finished ? 1 : dir} reduced={reduced}>
{finished ? (
<div className="py-5">{done}</div>
) : (
<div className="pb-1 pt-1.5">
{step.description && <div className="mb-4 text-[13px] leading-relaxed text-muted-foreground">{step.description}</div>}
{step.content}
</div>
)}
</Panel>
{/* What stopped you, folding open under the step. */}
<div className="grid" style={{ gridTemplateRows: error && !finished ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 300ms ${EASE}` }}>
<div className="overflow-hidden">
<p role="alert" className="flex items-start gap-1.5 pt-2.5 text-[12.5px] leading-snug text-red-600 dark:text-red-400">
{error && <AlertCircle aria-hidden="true" className="mt-px size-3.5 shrink-0" />}
{error}
</p>
</div>
</div>
</div>
{/* Footer: Back folds away on the first step, everything folds away once it's done. */}
<div className="grid" style={{ gridTemplateRows: finished ? "0fr" : "1fr", transition: reduced ? "none" : `grid-template-rows 420ms ${EASE}` }}>
<div className="overflow-hidden">
<div className="flex items-center gap-2 p-4 pt-5 @md:p-5">
<div className="grid" style={{ gridTemplateColumns: index > 0 ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-columns 360ms ${EASE}` }}>
<div className="overflow-hidden">
<button
type="button"
tabIndex={index > 0 ? 0 : -1}
aria-hidden={index === 0}
disabled={working}
onClick={() => go(index - 1)}
className={`h-9 whitespace-nowrap rounded-full px-3.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50 ${FOCUS}`}
>
Back
</button>
</div>
</div>
<StatusButton type="submit" status={status} labels={labels} className="ml-auto" />
</div>
</div>
</div>
</form>
);
}
Registry item: /r/steps-form.json