Newsletter Pill

A morphing subscribe pill that moves between CTA, email form, and success with shared layout animation and a confetti burst.

Inspired by Nitish Khagwal. Rebuilt from scratch with accessibility, reduced motion support, duplicate detection, and clearer error handling.

A single pill morphs through three states (CTA, email form, success) using Framer Motion LayoutGroup and shared layoutId values. Size, radius, icon, and label interpolate instead of unmounting.

You need an API endpoint

This component POSTs { email } to /api/subscribe by default (or whatever you pass as endpoint). Wire that route in your project, or pass onSubscribe to handle the request yourself.

// app/api/subscribe/route.ts (example)
export async function POST(req: Request) {
  const { email } = await req.json();
  // save email, handle 409 if already subscribed
  return Response.json({ ok: true });
}

import NewsletterPill from "@/components/NewsletterPill";

export default function Footer() {
  return (
    <NewsletterPill
      endpoint="/api/subscribe"
      ctaLabel="Notify Me"
      placeholder="you@company.com"
    />
  );
}

Install dependencies

npm install framer-motion lucide-react

Source code

NewsletterPill.tsx
"use client";

import { cn } from "@/lib/utils";
import { AnimatePresence, LayoutGroup, motion } from "framer-motion";
import { useId, useState } from "react";

type Status = "idle" | "loading" | "success" | "error" | "duplicate";

const morphSpring = {
  type: "spring" as const,
  stiffness: 400,
  damping: 22,
  mass: 0.9,
};

type Particle = {
  id: number;
  x: number;
  y: number;
  rotate: number;
  color: string;
  size: number;
  shape: "circle" | "square";
};

const DEFAULT_CONFETTI = [
  "#002F9E",
  "#003BC4",
  "#E8E6E3",
  "#FFFFFF",
  "#4E7DFF",
];

const isValidEmail = (email: string) =>
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());

export interface NewsletterPillProps {
  endpoint?: string;
  ctaLabel?: string;
  submitLabel?: string;
  successMessage?: React.ReactNode;
  placeholder?: string;
  onSubscribe?: (email: string) => Promise<void> | void;
  className?: string;
  showIcon?: boolean;
  accentColor?: string;
  confettiColors?: string[];
}

function BellIcon() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      className="block"
      aria-hidden
    >
      <path
        d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

export default function NewsletterPill({
  endpoint = "/api/subscribe",
  ctaLabel = "Notify Me",
  submitLabel = "Subscribe",
  successMessage = (
    <>
      You&apos;re in the <span className="text-primary">club</span> 🎉
    </>
  ),
  placeholder = "your@email.com",
  onSubscribe,
  className,
  showIcon = true,
  accentColor,
  confettiColors = DEFAULT_CONFETTI,
}: NewsletterPillProps) {
  // Unique per mount critical so reset remounts cleanly
  const uid = useId();
  const pillId = `${uid}-pill`;
  const iconId = `${uid}-icon`;
  const textId = `${uid}-text`;

  const [expanded, setExpanded] = useState(false);
  const [email, setEmail] = useState("");
  const [status, setStatus] = useState<Status>("idle");
  const [errorMsg, setErrorMsg] = useState("");
  const [particles, setParticles] = useState<Particle[]>([]);

  const showError = status === "error" || status === "duplicate";

  const solidStyle = accentColor
    ? { backgroundColor: accentColor, color: "#fff" }
    : undefined;

  const solidClass = accentColor
    ? "hover:opacity-90 active:opacity-80"
    : "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80";

  const triggerConfetti = () => {
    const next: Particle[] = Array.from({ length: 120 }, (_, i) => ({
      id: Date.now() + i,
      x: (Math.random() - 0.5) * 800,
      y: -(Math.random() * 500 + 100),
      rotate: Math.random() * 1080 - 540,
      color: confettiColors[Math.floor(Math.random() * confettiColors.length)],
      size: Math.random() * 8 + 5,
      shape: Math.random() > 0.5 ? "circle" : "square",
    }));
    setParticles(next);
    window.setTimeout(() => setParticles([]), 3000);
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!email || status === "loading") return;

    if (!isValidEmail(email)) {
      setErrorMsg("Please enter a valid email address.");
      setStatus("error");
      return;
    }

    setStatus("loading");
    setErrorMsg("");

    try {
      if (onSubscribe) {
        await onSubscribe(email.trim().toLowerCase());
      } else {
        const res = await fetch(endpoint, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email: email.trim().toLowerCase() }),
        });

        const data = await res.json().catch(() => ({}));

        if (res.status === 409 || data.alreadySubscribed) {
          setErrorMsg("You're already subscribed. Thanks for the love.");
          setStatus("duplicate");
          return;
        }

        if (!res.ok) {
          throw new Error(data.error || "Failed");
        }
      }

      window.setTimeout(() => {
        setStatus("success");
        setEmail("");
        triggerConfetti();
      }, 250);
    } catch {
      setErrorMsg("Something went wrong. Please try again.");
      setStatus("error");
    }
  };

  return (
    <div
      className={cn(
        "relative flex flex-col items-center justify-center",
        className
      )}
    >
      <div className="relative">
        <AnimatePresence>
          {particles.map((p) => (
            <motion.span
              key={p.id}
              initial={{ x: 0, y: 0, opacity: 1, rotate: 0, scale: 1 }}
              animate={{
                x: p.x,
                y: p.y,
                opacity: 0,
                rotate: p.rotate,
                scale: 0.4,
              }}
              transition={{ duration: 2.5, ease: [0.22, 1, 0.36, 1] }}
              className="pointer-events-none absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2"
              style={{
                width: p.size,
                height: p.size,
                backgroundColor: p.color,
                borderRadius: p.shape === "circle" ? "50%" : "2px",
              }}
            />
          ))}
        </AnimatePresence>

        <LayoutGroup id={uid}>
          {status !== "success" && !expanded && (
            <motion.button
              key="button"
              type="button"
              layoutId={pillId}
              onClick={() => setExpanded(true)}
              transition={morphSpring}
              style={solidStyle}
              className={cn(
                "relative z-0 flex items-center gap-2.5 rounded-full px-6 py-3.5 text-sm font-medium",
                "transition-colors duration-150",
                solidClass
              )}
            >
              {showIcon ? (
                <motion.span layoutId={iconId} className="flex items-center">
                  <BellIcon />
                </motion.span>
              ) : null}
              <motion.span layoutId={textId}>{ctaLabel}</motion.span>
            </motion.button>
          )}

          {status !== "success" && expanded && (
            <motion.form
              key="form"
              layoutId={pillId}
              onSubmit={handleSubmit}
              transition={morphSpring}
              className={cn(
                "relative z-0 flex items-center gap-2 rounded-full border bg-card p-1.5 pl-5",
                showError ? "border-red-500/60" : "border-border"
              )}
            >
              <input
                type="email"
                autoFocus
                required
                value={email}
                onChange={(e) => {
                  setEmail(e.target.value);
                  if (showError) {
                    setStatus("idle");
                    setErrorMsg("");
                  }
                }}
                placeholder={placeholder}
                disabled={status === "loading"}
                spellCheck={false}
                autoComplete="email"
                className="w-56 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/50 disabled:opacity-60 md:w-72"
                onBlur={() => {
                  if (!email && status !== "loading") setExpanded(false);
                }}
              />
              <button
                type="submit"
                disabled={status === "loading"}
                style={solidStyle}
                className={cn(
                  "flex items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium",
                  "transition-colors duration-150 disabled:opacity-70",
                  solidClass
                )}
              >
                {status === "loading" ? (
                  <span>Subscribing</span>
                ) : (
                  <span>{submitLabel}</span>
                )}
              </button>
            </motion.form>
          )}

          {status === "success" && (
            <motion.div
              key="success"
              layoutId={pillId}
              transition={morphSpring}
              className="relative z-0 px-6 py-3 text-2xl font-semibold tracking-tight text-foreground md:text-3xl"
              role="status"
              aria-live="polite"
            >
              <motion.span layoutId={textId}>{successMessage}</motion.span>
            </motion.div>
          )}
        </LayoutGroup>
      </div>

      <AnimatePresence>
        {showError ? (
          <motion.p
            initial={{ opacity: 0, y: -5 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0 }}
            className={cn(
              "mt-4 text-sm",
              status === "duplicate"
                ? "text-muted-foreground"
                : "text-red-500 dark:text-red-400"
            )}
          >
            {errorMsg}
          </motion.p>
        ) : null}
      </AnimatePresence>
    </div>
  );
}