freeNew143 lines · framer-motion
Waitlist Form
Leave an email, get a clean 'you're in'. A centred call-to-action card with a single email field for joining a waitlist: inline validation on submit, and a styled success state that replaces the form entirely — no network call, no invented queue position.
- Use it for
- A pre-launch or capacity-gated product page where the only action available yet is 'leave your email', without pretending there's a live signup count behind it.
- Themes
- Light + dark
- License
- Commercial use
Live
Early access
Get on the list
We're letting workspaces in gradually. Leave your email and we'll send an invite.
tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
export interface WaitlistFormProps {
eyebrow?: string;
title?: string;
subcopy?: string;
placeholder?: string;
buttonLabel?: string;
successTitle?: string;
successBody?: string;
className?: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function WaitlistForm({
eyebrow = "Early access",
title = "Get on the list",
subcopy = "We're letting workspaces in gradually. Leave your email and we'll send an invite.",
placeholder = "[email protected]",
buttonLabel = "Join the waitlist",
successTitle = "You're on the list",
successBody = "We'll email you the moment a spot opens up. No spam in the meantime.",
className,
}: WaitlistFormProps) {
const reduce = useReducedMotionSafe();
const [email, setEmail] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [status, setStatus] = React.useState<"idle" | "submitting" | "success">("idle");
const inputId = React.useId();
const errorId = React.useId();
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = email.trim();
if (!trimmed) {
setError("Enter your email address.");
return;
}
if (!EMAIL_RE.test(trimmed)) {
setError("That doesn't look like a valid email.");
return;
}
setError(null);
setStatus("submitting");
timeoutRef.current = setTimeout(() => setStatus("success"), 500);
};
return (
<section className={cn("w-full bg-background px-4 py-20 sm:px-6 lg:px-10", className)}>
<div className="mx-auto max-w-xl overflow-hidden rounded-[2rem] border-2 border-foreground bg-card p-8 text-center shadow-[10px_10px_0_0_var(--signal)] sm:p-12">
<AnimatePresence mode="wait">
{status !== "success" ? (
<motion.div key="form" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={reduce ? undefined : { opacity: 0 }} transition={{ duration: 0.2 }}>
<p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{eyebrow}</p>
<h2 className="mt-3 font-display text-[clamp(1.8rem,4.5vw,3rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">{title}</h2>
<p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">{subcopy}</p>
<form
noValidate
onSubmit={handleSubmit}
className="mx-auto mt-7 flex max-w-sm flex-col items-stretch gap-3 sm:flex-row"
>
<div className="flex-1 text-left">
<label htmlFor={inputId} className="sr-only">
Email address
</label>
<input
id={inputId}
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (error) setError(null);
}}
placeholder={placeholder}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
className={cn(
"h-12 w-full rounded-full border-2 bg-background px-5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
error ? "border-destructive" : "border-border focus-visible:border-foreground",
)}
/>
</div>
<button
type="submit"
disabled={status === "submitting"}
className="inline-flex h-12 shrink-0 items-center justify-center rounded-full bg-primary px-6 text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-60"
>
{status === "submitting" ? "Joining…" : buttonLabel}
</button>
</form>
<p id={errorId} className="mt-2.5 min-h-[1.1rem] text-sm font-medium text-destructive" aria-live="polite">
{error ?? ""}
</p>
</motion.div>
) : (
<motion.div
key="success"
initial={reduce ? false : { opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
role="status"
aria-live="polite"
>
<motion.span
initial={reduce ? false : { scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 18, delay: 0.05 }}
className="mx-auto grid size-14 place-items-center rounded-full bg-ok"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="size-7 text-background">
<path d="M5 13l4 4L19 7" />
</svg>
</motion.span>
<h3 className="mt-5 font-display text-2xl font-bold tracking-tight text-foreground">{successTitle}</h3>
<p className="mx-auto mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">{successBody}</p>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}
export default WaitlistForm;
How it behaves
- →Submitting with an empty or malformed email shows an inline error under the field and never proceeds.
- →A valid submission shows a brief 'joining' state on the button, then cross-fades the whole form out for a success panel with a checkmark and confirmation copy.
- →No request is ever sent; the 'submitting' delay is a short local timeout purely so the state change is visible.
- →The form-to-success cross-fade is skipped under prefers-reduced-motion.
Accessibility
- ✓The email field has a real (visually hidden) `<label>`, and the error message is tied to it with `aria-describedby` plus `aria-invalid`.
- ✓The success panel has `role="status"` and `aria-live="polite"` so screen reader users hear the confirmation without extra navigation.
- ✓Honours prefers-reduced-motion.