Newsletter Form
Email capture with validation, loading and success states.
Stay in the loop
Product updates and design notes, once a month.
components/ui/newsletter-form.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { ArrowRight, Check, Loader2 } from "lucide-react";
import { useState, type FormEvent } from "react";
type Status = "idle" | "loading" | "success";
type NewsletterFormProps = {
/** Called with the email. Return a promise; reject it to show an error. */
onSubscribe?: (email: string) => Promise<void>;
};
export function NewsletterForm({ onSubscribe = () => new Promise((r) => setTimeout(r, 1200)) }: NewsletterFormProps) {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState("");
async function submit(e: FormEvent) {
e.preventDefault();
if (!/^\S+@\S+\.\S+$/.test(email)) return setError("Please enter a valid email address.");
setError("");
setStatus("loading");
try {
await onSubscribe(email);
setStatus("success");
} catch {
setStatus("idle");
setError("Something went wrong. Please try again.");
}
}
return (
<div className="w-full">
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div
key="done"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center gap-3 rounded-full border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm text-emerald-800"
>
<motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", delay: 0.1 }} className="grid size-6 place-items-center rounded-full bg-emerald-600 text-white">
<Check className="size-3.5" />
</motion.span>
You're subscribed. Check your inbox to confirm.
</motion.div>
) : (
<motion.form
key="form"
exit={{ opacity: 0, scale: 0.97 }}
onSubmit={submit}
noValidate
className="flex items-center gap-2 rounded-full border border-zinc-200 bg-white p-1.5 shadow-sm transition focus-within:border-blue-600 focus-within:ring-4 focus-within:ring-blue-600/10"
>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
aria-label="Email address"
className="min-w-0 flex-1 bg-transparent px-4 text-sm text-zinc-900 outline-none placeholder:text-zinc-400"
/>
<button disabled={status === "loading"} className="inline-flex items-center gap-2 rounded-full bg-zinc-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-70">
{status === "loading" ? <Loader2 className="size-4 animate-spin" /> : <>Subscribe <ArrowRight className="size-4" /></>}
</button>
</motion.form>
)}
</AnimatePresence>
{error && <p className="mt-2 px-4 text-xs text-rose-600">{error}</p>}
</div>
);
}
export default function Demo() {
return (
<div className="w-full max-w-md text-center">
<h3 className="text-2xl font-semibold tracking-tight text-zinc-900">Stay in the loop</h3>
<p className="mb-6 mt-2 text-sm text-zinc-500">Product updates and design notes, once a month.</p>
<NewsletterForm />
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/newsletter-form.tsxcomponents/ui/newsletter-form.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { ArrowRight, Check, Loader2 } from "lucide-react"; import { useState, type FormEvent } from "react"; type Status = "idle" | "loading" | "success"; type NewsletterFormProps = { /** Called with the email. Return a promise; reject it to show an error. */ onSubscribe?: (email: string) => Promise<void>; }; export function NewsletterForm({ onSubscribe = () => new Promise((r) => setTimeout(r, 1200)) }: NewsletterFormProps) { const [email, setEmail] = useState(""); const [status, setStatus] = useState<Status>("idle"); const [error, setError] = useState(""); async function submit(e: FormEvent) { e.preventDefault(); if (!/^\S+@\S+\.\S+$/.test(email)) return setError("Please enter a valid email address."); setError(""); setStatus("loading"); try { await onSubscribe(email); setStatus("success"); } catch { setStatus("idle"); setError("Something went wrong. Please try again."); } } return ( <div className="w-full"> <AnimatePresence mode="wait" initial={false}> {status === "success" ? ( <motion.div key="done" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="flex items-center gap-3 rounded-full border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm text-emerald-800" > <motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: "spring", delay: 0.1 }} className="grid size-6 place-items-center rounded-full bg-emerald-600 text-white"> <Check className="size-3.5" /> </motion.span> You're subscribed. Check your inbox to confirm. </motion.div> ) : ( <motion.form key="form" exit={{ opacity: 0, scale: 0.97 }} onSubmit={submit} noValidate className="flex items-center gap-2 rounded-full border border-zinc-200 bg-white p-1.5 shadow-sm transition focus-within:border-blue-600 focus-within:ring-4 focus-within:ring-blue-600/10" > <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" aria-label="Email address" className="min-w-0 flex-1 bg-transparent px-4 text-sm text-zinc-900 outline-none placeholder:text-zinc-400" /> <button disabled={status === "loading"} className="inline-flex items-center gap-2 rounded-full bg-zinc-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:opacity-70"> {status === "loading" ? <Loader2 className="size-4 animate-spin" /> : <>Subscribe <ArrowRight className="size-4" /></>} </button> </motion.form> )} </AnimatePresence> {error && <p className="mt-2 px-4 text-xs text-rose-600">{error}</p>} </div> ); } export default function Demo() { return ( <div className="w-full max-w-md text-center"> <h3 className="text-2xl font-semibold tracking-tight text-zinc-900">Stay in the loop</h3> <p className="mb-6 mt-2 text-sm text-zinc-500">Product updates and design notes, once a month.</p> <NewsletterForm /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.