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-reactSource 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'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>
);
}