Newsletter Modal
Original · freenewsletter capture modal
byWeb InnoventixReact + Tailwind
modalnewslettermodals
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/modal-newsletter.jsonmodal-newsletter.tsx
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ChangeEvent,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "idle" | "submitting" | "success";
type Cadence = "weekly" | "biweekly" | "monthly";
const CADENCE_OPTIONS: ReadonlyArray<{
value: Cadence;
label: string;
hint: string;
}> = [
{ value: "weekly", label: "Weekly", hint: "Every Tuesday, 7am" },
{ value: "biweekly", label: "Every other week", hint: "Two issues a month" },
{ value: "monthly", label: "Monthly", hint: "The long-read edition" },
];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const FOCUSABLE =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
function MailGlyph() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-full w-full"
>
<rect x="3" y="5" width="18" height="14" rx="2.5" />
<path d="m3.5 7 7.3 5.1a2 2 0 0 0 2.4 0L20.5 7" />
</svg>
);
}
export default function ModalNewsletter() {
const [open, setOpen] = useState<boolean>(false);
const [email, setEmail] = useState<string>("");
const [touched, setTouched] = useState<boolean>(false);
const [cadence, setCadence] = useState<Cadence>("weekly");
const [consent, setConsent] = useState<boolean>(true);
const [status, setStatus] = useState<Status>("idle");
const reduce = useReducedMotion();
const uid = useId();
const titleId = `${uid}-title`;
const descId = `${uid}-desc`;
const emailErrId = `${uid}-email-err`;
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const emailRef = useRef<HTMLInputElement | null>(null);
const doneRef = useRef<HTMLButtonElement | null>(null);
const prevActive = useRef<HTMLElement | null>(null);
const valid = EMAIL_RE.test(email.trim());
const showEmailErr = touched && !valid && status !== "submitting";
const closeModal = useCallback(() => {
setOpen(false);
}, []);
// Reset the form shortly after the modal has closed.
useEffect(() => {
if (open) return;
const t = window.setTimeout(() => {
setStatus("idle");
setEmail("");
setTouched(false);
setCadence("weekly");
setConsent(true);
}, 260);
return () => window.clearTimeout(t);
}, [open]);
// Body scroll lock + focus restoration while the modal is open.
useEffect(() => {
if (!open) return;
prevActive.current = (document.activeElement as HTMLElement | null) ?? null;
const body = document.body;
const prevOverflow = body.style.overflow;
body.style.overflow = "hidden";
const t = window.setTimeout(() => {
emailRef.current?.focus();
}, 60);
return () => {
window.clearTimeout(t);
body.style.overflow = prevOverflow;
prevActive.current?.focus?.();
};
}, [open]);
// Move focus to the confirmation action once we reach success.
useEffect(() => {
if (status !== "success") return;
const t = window.setTimeout(() => doneRef.current?.focus(), 80);
return () => window.clearTimeout(t);
}, [status]);
const onPanelKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.stopPropagation();
closeModal();
return;
}
if (e.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const nodes = panel.querySelectorAll<HTMLElement>(FOCUSABLE);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
const active = document.activeElement;
if (e.shiftKey) {
if (active === first || !panel.contains(active)) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
},
[closeModal],
);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setTouched(true);
if (!EMAIL_RE.test(email.trim())) {
emailRef.current?.focus();
return;
}
setStatus("submitting");
window.setTimeout(() => setStatus("success"), 1150);
};
const subscribeAnother = () => {
setStatus("idle");
setEmail("");
setTouched(false);
window.setTimeout(() => emailRef.current?.focus(), 40);
};
const onEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const overlayTransition = { duration: reduce ? 0.15 : 0.22, ease: "easeOut" as const };
const panelInit = reduce ? { opacity: 0 } : { opacity: 0, y: 18, scale: 0.965 };
const panelAnim = { opacity: 1, y: 0, scale: 1 };
const panelExit = reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.985 };
const panelTransition = {
duration: reduce ? 0.15 : 0.32,
ease: [0.22, 1, 0.36, 1] as [number, number, number, number],
};
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 text-slate-900 sm:px-8 dark:bg-zinc-950 dark:text-zinc-50">
<style>{`
@keyframes nlmodal-shimmer {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
@keyframes nlmodal-float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-10px) rotate(3deg); }
}
@keyframes nlmodal-pop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes nlmodal-ring {
0% { transform: scale(0.6); opacity: 0.9; }
100% { transform: scale(1.9); opacity: 0; }
}
.nlmodal-shimmer {
background-size: 200% 100%;
animation: nlmodal-shimmer 5s linear infinite;
}
.nlmodal-float { animation: nlmodal-float 8s ease-in-out infinite; }
.nlmodal-pop { animation: nlmodal-pop 0.5s cubic-bezier(0.22,1,0.36,1) both; }
.nlmodal-ring { animation: nlmodal-ring 1.4s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.nlmodal-shimmer,
.nlmodal-float,
.nlmodal-pop,
.nlmodal-ring { animation: none !important; }
}
`}</style>
{/* Ambient background field */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="absolute -left-24 top-6 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
<div className="absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-700/20" />
</div>
<div className="relative mx-auto max-w-2xl">
{/* Preview card / trigger */}
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-[0_20px_60px_-30px_rgba(30,27,75,0.45)] dark:border-zinc-800 dark:bg-zinc-900">
<div className="relative border-b border-slate-200 bg-gradient-to-br from-indigo-50 via-white to-violet-50 px-8 py-9 dark:border-zinc-800 dark:from-indigo-950/40 dark:via-zinc-900 dark:to-violet-950/30">
<span className="nlmodal-float absolute right-8 top-8 grid h-11 w-11 place-items-center rounded-2xl bg-indigo-600 text-white shadow-lg shadow-indigo-600/30">
<span className="h-5 w-5">
<MailGlyph />
</span>
</span>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
The Signal
</p>
<h2 className="mt-3 max-w-md font-serif text-3xl leading-tight tracking-tight text-slate-900 sm:text-4xl dark:text-zinc-50">
One sharp email for people who build.
</h2>
</div>
<div className="px-8 py-8">
<p className="max-w-md text-[15px] leading-relaxed text-slate-600 dark:text-zinc-400">
A hand-picked digest of the tools, patterns, and teardown notes we
actually use — no cross-posts, no reheated hot takes. Join{" "}
<span className="font-semibold text-slate-900 dark:text-zinc-100">
12,438
</span>{" "}
designers and engineers reading it each week.
</p>
<div className="mt-7 flex flex-col gap-4 sm:flex-row sm:items-center">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center justify-center gap-2 rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white outline-none transition hover:bg-slate-800 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-white dark:focus-visible:ring-offset-zinc-900"
>
Subscribe to The Signal
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-4 w-4"
>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</svg>
</button>
<span className="text-xs text-slate-500 dark:text-zinc-500">
Free forever · Unsubscribe in one click
</span>
</div>
</div>
</div>
</div>
<AnimatePresence>
{open ? (
<motion.div
key="nlmodal-overlay"
className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={overlayTransition}
>
{/* Backdrop */}
<div
aria-hidden="true"
onClick={closeModal}
className="absolute inset-0 bg-slate-950/55 backdrop-blur-sm"
/>
{/* Panel */}
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
onKeyDown={onPanelKeyDown}
initial={panelInit}
animate={panelAnim}
exit={panelExit}
transition={panelTransition}
className="relative z-10 w-full max-w-md overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-900"
>
{/* Header ribbon */}
<div className="relative overflow-hidden px-7 pb-6 pt-7">
<div
aria-hidden="true"
className="nlmodal-shimmer absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-indigo-500 via-violet-500 to-indigo-500"
/>
<button
type="button"
onClick={closeModal}
aria-label="Close newsletter dialog"
className="absolute right-4 top-4 grid h-9 w-9 place-items-center rounded-full text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-offset-zinc-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={{ height: "1.125rem", width: "1.125rem" }}
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-indigo-600 text-white shadow-lg shadow-indigo-600/30">
<span className="h-6 w-6">
<MailGlyph />
</span>
</span>
<h3
id={titleId}
className="mt-4 font-serif text-2xl leading-snug tracking-tight text-slate-900 dark:text-zinc-50"
>
{status === "success"
? "You're on the list."
: "Get The Signal in your inbox"}
</h3>
<p
id={descId}
className="mt-1.5 text-sm leading-relaxed text-slate-600 dark:text-zinc-400"
>
{status === "success"
? "Check your inbox for a quick confirmation link. Tap it and your first issue is on the way."
: "Pick a rhythm, drop your email, and we'll handle the rest. No noise, ever."}
</p>
</div>
{/* Body */}
{status === "success" ? (
<div className="px-7 pb-8 pt-1">
<div className="flex items-center gap-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/40">
<span className="relative grid h-12 w-12 shrink-0 place-items-center">
<span
aria-hidden="true"
className="nlmodal-ring absolute inset-0 rounded-full bg-emerald-400/40"
/>
<span className="nlmodal-pop relative grid h-12 w-12 place-items-center rounded-full bg-emerald-500 text-white">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-6 w-6"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
</span>
<div className="min-w-0">
<p
className="text-sm font-semibold text-emerald-900 dark:text-emerald-200"
aria-live="polite"
>
Subscription confirmed
</p>
<p className="mt-0.5 truncate text-sm text-emerald-800/80 dark:text-emerald-300/70">
{email.trim()} ·{" "}
{CADENCE_OPTIONS.find((c) => c.value === cadence)?.label}
</p>
</div>
</div>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<button
ref={doneRef}
type="button"
onClick={closeModal}
className="inline-flex flex-1 items-center justify-center rounded-full bg-slate-900 px-5 py-3 text-sm font-semibold text-white outline-none transition hover:bg-slate-800 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-white dark:focus-visible:ring-offset-zinc-900"
>
Done
</button>
<button
type="button"
onClick={subscribeAnother}
className="inline-flex flex-1 items-center justify-center rounded-full border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 outline-none transition hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
>
Add another email
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} noValidate className="px-7 pb-7 pt-1">
<fieldset className="min-w-0 border-0 p-0">
<legend className="mb-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-zinc-500">
How often?
</legend>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-3">
{CADENCE_OPTIONS.map((opt) => {
const active = cadence === opt.value;
return (
<label
key={opt.value}
className={[
"group relative flex cursor-pointer flex-col gap-0.5 rounded-2xl border p-3 transition",
"focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 focus-within:ring-offset-white dark:focus-within:ring-offset-zinc-900",
active
? "border-indigo-500 bg-indigo-50 dark:border-indigo-500 dark:bg-indigo-950/50"
: "border-slate-200 bg-white hover:border-slate-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700",
].join(" ")}
>
<input
type="radio"
name="cadence"
value={opt.value}
checked={active}
onChange={() => setCadence(opt.value)}
className="sr-only"
/>
<span
className={[
"text-sm font-semibold",
active
? "text-indigo-700 dark:text-indigo-300"
: "text-slate-800 dark:text-zinc-200",
].join(" ")}
>
{opt.label}
</span>
<span
className={[
"text-xs",
active
? "text-indigo-600/80 dark:text-indigo-400/80"
: "text-slate-500 dark:text-zinc-500",
].join(" ")}
>
{opt.hint}
</span>
</label>
);
})}
</div>
</fieldset>
<div className="mt-5">
<label
htmlFor={`${uid}-email`}
className="mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-zinc-500"
>
Email address
</label>
<div className="relative">
<span
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400 dark:text-zinc-500"
>
<MailGlyph />
</span>
<input
ref={emailRef}
id={`${uid}-email`}
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@studio.com"
value={email}
onChange={onEmailChange}
onBlur={() => setTouched(true)}
aria-invalid={showEmailErr}
aria-describedby={showEmailErr ? emailErrId : undefined}
className={[
"w-full rounded-2xl border bg-white py-3 pl-11 pr-4 text-[15px] text-slate-900 outline-none transition placeholder:text-slate-400",
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-950/40 dark:text-zinc-50 dark:placeholder:text-zinc-600 dark:focus-visible:ring-offset-zinc-900",
showEmailErr
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
: "border-slate-300 focus-visible:ring-indigo-500 dark:border-zinc-700",
].join(" ")}
/>
</div>
<div className="mt-1.5 min-h-[1.1rem]" aria-live="polite">
{showEmailErr ? (
<p
id={emailErrId}
className="flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-3.5 w-3.5"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
Please enter a valid email address.
</p>
) : null}
</div>
</div>
<label className="mt-1 flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 outline-none accent-indigo-600 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-600 dark:focus-visible:ring-offset-zinc-900"
/>
<span className="text-xs leading-relaxed text-slate-600 dark:text-zinc-400">
Send me the occasional product note (a new teardown or tool).
Never more than one extra email a month.
</span>
</label>
<button
type="submit"
disabled={status === "submitting"}
className="mt-5 inline-flex w-full items-center justify-center gap-2 rounded-full bg-indigo-600 px-6 py-3.5 text-sm font-semibold text-white outline-none transition hover:bg-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-70 dark:focus-visible:ring-offset-zinc-900"
>
{status === "submitting" ? (
<>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className="h-4 w-4 animate-spin"
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeOpacity={0.25}
strokeWidth={3}
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
/>
</svg>
Subscribing…
</>
) : (
"Join 12,438 readers"
)}
</button>
<p className="mt-3 text-center text-xs text-slate-500 dark:text-zinc-500">
We use your email only for The Signal. Unsubscribe anytime.
</p>
</form>
)}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Modal
Originalbasic centred modal with overlay

Center Modal
Originalcentred modal with icon and actions

Form Modal
Originalmodal containing a form

Confirm Modal
Originalconfirm/destructive dialog

Alert Modal
Originalalert dialog with single action

Drawer Bottom Modal
Originalbottom sheet drawer

Side Drawer Modal
Originalright side drawer modal

Fullscreen Modal
Originalfullscreen modal

Image Lightbox Modal
Originalimage lightbox modal with nav

Video Modal
Originalvideo player modal

Scroll Modal
Originalmodal with long scrollable body

Steps Modal
Originalmulti-step modal flow

