Website Sections
ContactFlow
A contact form taken one step at a time. The card reshapes to fit each step, focus moves with you, and the sent state draws a tick.
Download
- React
- Tailwind CSS v4
- motion
components/ui/contact-flow.tsx
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type ElementType, type FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type ContactFlowData = { topic: string; name: string; email: string; message: string };
const LABELS = {
topic: "What is this about?",
name: "Your name",
email: "Email",
message: "How can we help?",
next: "Next",
back: "Back",
send: "Send message",
sending: "Sending…",
step: "Step",
of: "of",
pickTopic: "Pick one to go on.",
needName: "Please add your name.",
needEmail: "Please add a valid email.",
needMessage: "Please write a short message.",
failed: "That did not send. Please try again.",
doneTitle: "Message sent",
doneText: "Thanks. We will write back soon.",
again: "Send another",
};
type ContactFlowProps = {
/** The choices on the first step. */
topics: string[];
/** Sends the message. Throw or reject to show the error line. */
onSubmit: (data: ContactFlowData) => void | Promise<void>;
title?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Other ways to reach you, beside the form. */
channels?: { label: string; value: string; href?: string }[];
/** Every word in the form, for other languages. Give only the ones to change. */
labels?: Partial<typeof LABELS>;
/** Colour of the bar, the choice and the main button. */
color?: string;
/** Text colour on that colour. */
ink?: string;
/** Card colour. */
tile?: string;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
const EMPTY: ContactFlowData = { topic: "", name: "", email: "", message: "" };
const STEPS = 3;
const FIELD =
"mt-1.5 w-full rounded-lg border border-current/15 bg-transparent px-3 py-2 text-sm font-normal outline-none transition-colors focus:border-[var(--cf-color)] aria-invalid:border-red-400";
/* ContactFlow: a contact form taken one step at a time. The card reshapes to fit each step, focus moves with you, and the sent state draws a tick. It only collects: you send the data in onSubmit. */
export function ContactFlow({
topics,
onSubmit,
title,
eyebrow,
subtitle,
channels,
labels,
color = "#a78bfa",
ink = "#0b0a14",
tile = "#15122a",
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: ContactFlowProps) {
const reduce = useReducedMotion();
const id = useId();
const words = { ...LABELS, ...labels };
const inner = useRef<HTMLDivElement>(null);
const card = useRef<HTMLFormElement>(null);
const moved = useRef(false);
const [height, setHeight] = useState<number>();
const [step, setStep] = useState(0);
const [way, setWay] = useState(1);
const [data, setData] = useState(EMPTY);
const [error, setError] = useState("");
const [state, setState] = useState<"open" | "sending" | "sent">("open");
const sent = state === "sent";
useEffect(() => {
const el = inner.current;
if (!el) return;
const watcher = new ResizeObserver(([entry]) => setHeight(entry.target.getBoundingClientRect().height));
watcher.observe(el);
return () => watcher.disconnect();
}, []);
useEffect(() => {
if (!moved.current) return;
card.current?.querySelector<HTMLElement>(`[data-step="${sent ? "done" : step}"] :is(input, textarea, button)`)?.focus({ preventScroll: true });
}, [sent, step]);
const set = (key: keyof ContactFlowData, value: string) => {
setData((now) => ({ ...now, [key]: value }));
setError("");
};
const check = () => {
if (step === 0 && !data.topic) return words.pickTopic;
if (step === 1 && !data.name.trim()) return words.needName;
if (step === 1 && !/^\S+@\S+\.\S+$/.test(data.email.trim())) return words.needEmail;
if (step === 2 && !data.message.trim()) return words.needMessage;
return "";
};
const go = (to: number) => {
moved.current = true;
setWay(to > step ? 1 : -1);
setError("");
setStep(to);
};
const submit = async (e: FormEvent) => {
e.preventDefault();
if (state === "sending") return;
const problem = check();
if (problem) return setError(problem);
if (step < STEPS - 1) return go(step + 1);
setState("sending");
setError("");
try {
await onSubmit(data);
moved.current = true;
setState("sent");
} catch {
setState("open");
setError(words.failed);
}
};
const again = () => {
setData(EMPTY);
setStep(0);
setState("open");
};
const errorFor = (bad: boolean) => (bad && error ? { "aria-invalid": true, "aria-describedby": `${id}-error` } : {});
const slide = {
initial: { opacity: 0, x: reduce ? 0 : way * 36 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: reduce ? 0 : way * -36 },
transition: { duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] as const },
};
return (
<section aria-labelledby={title ? id : undefined} className={`@container ${className}`} style={{ "--cf-color": color, "--cf-ink": ink } as CSSProperties}>
<div className="mx-auto grid max-w-5xl items-center gap-8 @3xl:grid-cols-2 @3xl:gap-12">
<div>
{title && (
<header>
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.5rem,6cqw,2.5rem)] leading-tight font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-pretty opacity-70">{subtitle}</p>}
</header>
)}
{channels && channels.length > 0 && (
<dl className="mt-6 grid gap-3 text-sm">
{channels.map((item) => (
<div key={item.label}>
<dt className="opacity-60">{item.label}</dt>
<dd className="font-medium">
{item.href ? (
<a href={item.href} className="underline-offset-2 hover:underline">
{item.value}
</a>
) : (
item.value
)}
</dd>
</div>
))}
</dl>
)}
</div>
<form ref={card} noValidate onSubmit={submit} aria-label={title ?? "Contact"} className="overflow-hidden rounded-2xl border border-current/10" style={{ backgroundColor: tile }}>
<div className="h-0.5 bg-current/10">
<motion.div
className="h-full origin-left bg-[var(--cf-color)]"
initial={false}
animate={{ scaleX: sent ? 1 : (step + 1) / STEPS }}
transition={{ duration: reduce ? 0 : 0.5, ease: [0.16, 1, 0.3, 1] }}
/>
</div>
<motion.div className="overflow-hidden" initial={false} animate={height ? { height } : undefined} transition={{ duration: reduce ? 0 : 0.35, ease: [0.16, 1, 0.3, 1] }}>
<div ref={inner} className="relative p-5">
<AnimatePresence mode="popLayout" initial={false}>
{sent ? (
<motion.div key="done" data-step="done" {...slide} role="status" className="py-4 text-center">
<svg viewBox="0 0 48 48" aria-hidden className="mx-auto size-14" fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<motion.circle cx="24" cy="24" r="21" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduce ? 0 : 0.5 }} />
<motion.path d="M14 25l7 7 13-15" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduce ? 0 : 0.35, delay: reduce ? 0 : 0.4 }} />
</svg>
<p className="mt-3 text-lg font-semibold">{words.doneTitle}</p>
<p className="mt-1 text-sm opacity-70">{words.doneText}</p>
<button type="button" onClick={again} className="mt-4 text-sm underline underline-offset-2">
{words.again}
</button>
</motion.div>
) : (
<motion.div key={step} data-step={step} {...slide}>
<p className="mb-3 text-xs opacity-60">
{words.step} {step + 1} {words.of} {STEPS}
</p>
{step === 0 && (
<fieldset aria-describedby={error ? `${id}-error` : undefined}>
<legend className="mb-2.5 font-semibold">{words.topic}</legend>
<div className="flex flex-wrap gap-2">
{topics.map((topic) => (
<label key={topic} className="cursor-pointer">
<input type="radio" name={`${id}-topic`} value={topic} checked={data.topic === topic} onChange={() => set("topic", topic)} className="peer sr-only" />
<span className="block rounded-full border border-current/15 px-3.5 py-1.5 text-sm transition-colors peer-checked:border-[var(--cf-color)] peer-checked:bg-[var(--cf-color)] peer-checked:text-[var(--cf-ink)] peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-current hover:border-current/40">
{topic}
</span>
</label>
))}
</div>
</fieldset>
)}
{step === 1 && (
<div className="grid gap-3">
<label className="block text-sm font-medium">
{words.name}
<input type="text" autoComplete="name" required value={data.name} onChange={(e) => set("name", e.target.value)} className={FIELD} {...errorFor(!data.name.trim())} />
</label>
<label className="block text-sm font-medium">
{words.email}
<input type="email" autoComplete="email" required value={data.email} onChange={(e) => set("email", e.target.value)} className={FIELD} {...errorFor(!!data.name.trim())} />
</label>
</div>
)}
{step === 2 && (
<label className="block text-sm font-medium">
{words.message}
<textarea rows={4} required value={data.message} onChange={(e) => set("message", e.target.value)} className={`${FIELD} resize-none`} {...errorFor(true)} />
</label>
)}
{error && (
<p id={`${id}-error`} role="alert" className="mt-2 text-sm text-red-300">
{error}
</p>
)}
<div className="mt-4 flex items-center justify-between gap-3">
{step > 0 ? (
<button type="button" onClick={() => go(step - 1)} className="text-sm opacity-70 hover:opacity-100">
{words.back}
</button>
) : (
<span />
)}
<button type="submit" disabled={state === "sending"} className="rounded-lg bg-[var(--cf-color)] px-4 py-2 text-sm font-medium text-[var(--cf-ink)] disabled:opacity-60">
{step < STEPS - 1 ? words.next : state === "sending" ? words.sending : words.send}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</form>
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/contact-flow.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/contact-flow-demo.tsx
"use client"; import { ContactFlow } from "@/components/ui/contact-flow"; const TOPICS = ["A new project", "Support", "Press", "Something else"]; const CHANNELS = [ { label: "Email", value: "hello@example.com", href: "mailto:hello@example.com" }, { label: "Hours", value: "Mon to Fri, 9 to 6" }, ]; // A stand-in for your own send. This demo sends nothing. const send = () => new Promise<void>((done) => setTimeout(done, 800)); export default function ContactFlowExample() { return ( <div className="h-full overflow-y-auto"> <ContactFlow title="Say hello" subtitle="This demo sends nothing." topics={TOPICS} channels={CHANNELS} onSubmit={send} headingAs="h3" className="min-h-full bg-[#0b0a14] p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| topics | string[] | — | The choices on the first step. |
| onSubmit | (data) => void | Promise<void> | — | Sends the message: topic, name, email, message. Throw or reject to show the error line. |
| title | string | — | Heading beside the form. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| channels | { label, value, href? }[] | — | Other ways to reach you, beside the form. |
| labels | object | English | Every word in the form. Give only the ones to change. |
| color | string | "#a78bfa" | Colour of the bar, the choice and the main button. |
| ink | string | "#0b0a14" | Text colour on that colour. |
| tile | string | "#15122a" | Card colour. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |