Newsletter Form

Email capture with validation, loading and success states.

Stay in the loop

Product updates and design notes, once a month.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/newsletter-form.tsx

    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&apos;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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.