Modal Newsletter
Original · freenewsletter popup card
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/newsx-modal.json"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import type {
ChangeEvent,
FormEvent,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "idle" | "submitting" | "success";
type Topic = {
readonly id: string;
readonly label: string;
readonly hint: string;
};
const TOPICS: readonly Topic[] = [
{ id: "shipping", label: "Shipping notes", hint: "What we launched, honestly" },
{ id: "engineering", label: "Engineering", hint: "Deep-dives, no fluff" },
{ id: "craft", label: "Design & craft", hint: "Details worth stealing" },
{ id: "field", label: "Field reports", hint: "Real teams, real numbers" },
];
const SUBSCRIBERS = [
{ initials: "AR", tone: "bg-indigo-500" },
{ initials: "MJ", tone: "bg-violet-500" },
{ initials: "KP", tone: "bg-emerald-500" },
{ initials: "TS", tone: "bg-amber-500" },
] as const;
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
function isValidEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
export default function NewsxModal() {
const reduce = useReducedMotion();
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const [topics, setTopics] = useState<readonly string[]>(["shipping"]);
const [status, setStatus] = useState<Status>("idle");
const dialogRef = useRef<HTMLDivElement | null>(null);
const emailRef = useRef<HTMLInputElement | null>(null);
const openerRef = useRef<HTMLElement | null>(null);
const timerRef = useRef<number | null>(null);
const uid = useId();
const titleId = `${uid}-title`;
const descId = `${uid}-desc`;
const errId = `${uid}-err`;
const topicsLabelId = `${uid}-topics`;
const emailInvalid = touched && !isValidEmail(email);
const openModal = useCallback((trigger?: HTMLElement | null) => {
openerRef.current =
trigger ?? (document.activeElement as HTMLElement | null);
setOpen(true);
}, []);
const closeModal = useCallback(() => {
setOpen(false);
}, []);
// Auto-surface once, like a real popup — but never fight reduced-motion users abruptly.
useEffect(() => {
const t = window.setTimeout(() => {
setOpen((prev) => prev || true);
}, 900);
return () => window.clearTimeout(t);
}, []);
// Body scroll lock while open.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Focus management: move focus in on open, restore it on close.
useEffect(() => {
if (open) {
const raf = window.requestAnimationFrame(() => {
emailRef.current?.focus();
});
return () => window.cancelAnimationFrame(raf);
}
const opener = openerRef.current;
if (opener && typeof opener.focus === "function") {
opener.focus();
}
// Reset transient form state after the exit animation would have run.
const t = window.setTimeout(() => {
setStatus("idle");
setTouched(false);
}, 260);
return () => window.clearTimeout(t);
}, [open]);
// Re-trap focus if the browser drops it outside the dialog — the submit
// button blurs to <body> the instant it becomes disabled, and from there the
// dialog's key handler can't fire, so Tab would reach the page behind it.
useEffect(() => {
if (!open || status === "idle") return;
const root = dialogRef.current;
if (!root) return;
const active = document.activeElement as HTMLElement | null;
if (active && root.contains(active)) return;
root.querySelector<HTMLElement>(FOCUSABLE)?.focus();
}, [open, status]);
useEffect(() => {
return () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
};
}, []);
const onKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.stopPropagation();
closeModal();
return;
}
if (event.key !== "Tab") return;
const root = dialogRef.current;
if (!root) return;
const nodes = Array.from(
root.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
const active = document.activeElement as HTMLElement | null;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
},
[closeModal],
);
const toggleTopic = useCallback((id: string) => {
setTopics((prev) =>
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id],
);
}, []);
const onSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setTouched(true);
if (!isValidEmail(email) || status === "submitting") return;
setStatus("submitting");
timerRef.current = window.setTimeout(() => {
setStatus("success");
}, 1100);
},
[email, status],
);
const onEmailChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setEmail(event.target.value);
}, []);
const onBackdrop = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) closeModal();
},
[closeModal],
);
const dur = reduce ? 0 : 0.32;
const backdropVariants = useMemo(
() => ({
hidden: { opacity: 0 },
show: { opacity: 1 },
}),
[],
);
const cardVariants = useMemo(
() => ({
hidden: reduce
? { opacity: 0 }
: { opacity: 0, y: 22, scale: 0.965 },
show: reduce
? { opacity: 1 }
: { opacity: 1, y: 0, scale: 1 },
exit: reduce
? { opacity: 0 }
: { opacity: 0, y: 14, scale: 0.975 },
}),
[reduce],
);
const selectedCount = topics.length;
return (
<section
aria-label="Newsletter sign-up"
className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-28 dark:bg-slate-950 dark:text-slate-100"
>
<style>{`
@keyframes newsx-sweep {
0% { transform: translateX(-120%) skewX(-12deg); }
100% { transform: translateX(240%) skewX(-12deg); }
}
@keyframes newsx-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes newsx-pulse-ring {
0% { transform: scale(0.85); opacity: 0.55; }
70% { transform: scale(1.5); opacity: 0; }
100% { opacity: 0; }
}
@keyframes newsx-draw {
from { stroke-dashoffset: 30; }
to { stroke-dashoffset: 0; }
}
.newsx-sweep { animation: newsx-sweep 3.4s ease-in-out infinite; }
.newsx-float { animation: newsx-float 4.5s ease-in-out infinite; }
.newsx-ring { animation: newsx-pulse-ring 2.4s ease-out infinite; }
.newsx-check { stroke-dasharray: 30; animation: newsx-draw 0.6s ease-out forwards; }
@media (prefers-reduced-motion: reduce) {
.newsx-sweep, .newsx-float, .newsx-ring, .newsx-check {
animation: none !important;
}
.newsx-check { stroke-dashoffset: 0; }
}
`}</style>
{/* Atmosphere */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 [background-image:radial-gradient(circle_at_18%_20%,rgba(99,102,241,0.10),transparent_45%),radial-gradient(circle_at_82%_78%,rgba(139,92,246,0.10),transparent_45%)]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.04] [background-image:linear-gradient(to_right,currentColor_1px,transparent_1px),linear-gradient(to_bottom,currentColor_1px,transparent_1px)] [background-size:44px_44px]"
/>
{/* Preview / trigger surface */}
<div className="relative mx-auto flex max-w-3xl flex-col items-center text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-indigo-700 backdrop-blur dark:border-indigo-400/25 dark:bg-slate-900/60 dark:text-indigo-300">
<span className="relative flex h-1.5 w-1.5">
<span className="newsx-ring absolute inline-flex h-full w-full rounded-full bg-indigo-500" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-indigo-500" />
</span>
Issue 048 · Friday
</span>
<h2 className="mt-6 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
The three things in product engineering worth your Friday.
</h2>
<p className="mt-4 max-w-xl text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
Signal Weekly is one short email — no growth hacks, no listicles.
Just the work our team actually paid attention to this week.
</p>
<button
type="button"
onClick={(e) => openModal(e.currentTarget)}
className="group mt-8 inline-flex items-center gap-2 rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-slate-900/20 outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-white dark:text-slate-900 dark:shadow-black/40 dark:focus-visible:ring-offset-slate-950"
>
Open the sign-up card
<svg
viewBox="0 0 24 24"
className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</button>
</div>
<AnimatePresence>
{open && (
<motion.div
key="newsx-backdrop"
variants={backdropVariants}
initial="hidden"
animate="show"
exit="hidden"
transition={{ duration: reduce ? 0 : 0.22 }}
onMouseDown={onBackdrop}
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/60 p-4 backdrop-blur-sm sm:items-center"
>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
onKeyDown={onKeyDown}
variants={cardVariants}
initial="hidden"
animate="show"
exit="exit"
transition={{ duration: dur, ease: [0.16, 1, 0.3, 1] }}
className="relative w-full max-w-md overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl shadow-slate-900/25 dark:border-slate-700/70 dark:bg-slate-900 dark:shadow-black/60"
>
{/* Close */}
<button
type="button"
onClick={closeModal}
aria-label="Close newsletter sign-up"
className="absolute right-3 top-3 z-20 inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-500 outline-none transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
{/* Header band */}
<div className="relative overflow-hidden bg-gradient-to-br from-indigo-600 via-violet-600 to-indigo-700 px-6 pb-8 pt-9">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<span className="newsx-sweep absolute inset-y-0 left-0 w-1/3 bg-white/15 blur-md" />
</div>
<div className="relative flex items-center gap-4">
<div className="newsx-float flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-white/15 ring-1 ring-white/30 backdrop-blur">
<svg
viewBox="0 0 24 24"
className="h-7 w-7 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="5" width="18" height="14" rx="2.5" />
<path d="m3.5 7 8.5 6 8.5-6" />
</svg>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-100/90">
Signal Weekly
</p>
<h3
id={titleId}
className="mt-1 text-xl font-semibold text-white"
>
Get the Friday dispatch
</h3>
</div>
</div>
</div>
{/* Body */}
<div className="px-6 pb-6 pt-5">
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div
key="success"
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.28 }}
className="py-6 text-center"
>
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-500/15">
<svg
viewBox="0 0 24 24"
className="h-8 w-8 text-emerald-600 dark:text-emerald-400"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path className="newsx-check" d="M5 13l4 4L19 7" />
</svg>
</div>
<h4 className="mt-5 text-lg font-semibold text-slate-900 dark:text-white">
You're on the list.
</h4>
<p className="mx-auto mt-2 max-w-xs text-sm leading-relaxed text-slate-600 dark:text-slate-400">
We sent a confirmation to{" "}
<span className="font-medium text-slate-900 dark:text-slate-200">
{email.trim()}
</span>
. Click the link inside to lock in your first issue this
Friday.
</p>
<button
type="button"
onClick={closeModal}
className="mt-6 inline-flex items-center justify-center rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white outline-none transition-colors 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-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
>
Done
</button>
</motion.div>
) : (
<motion.form
key="form"
onSubmit={onSubmit}
noValidate
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: reduce ? 0 : 0.2 }}
>
<p
id={descId}
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"
>
One email every Friday. Pick what you want to hear about —
skip the rest.
</p>
{/* Topics */}
<div className="mt-4" role="group" aria-labelledby={topicsLabelId}>
<div className="flex items-center justify-between">
<span
id={topicsLabelId}
className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400"
>
Interests
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
{selectedCount} selected
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{TOPICS.map((topic) => {
const active = topics.includes(topic.id);
return (
<button
key={topic.id}
type="button"
aria-pressed={active}
onClick={() => toggleTopic(topic.id)}
className={[
"group flex flex-col items-start gap-0.5 rounded-xl border px-3 py-2.5 text-left outline-none transition-all focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
active
? "border-indigo-500 bg-indigo-50 dark:border-indigo-400/60 dark:bg-indigo-500/10"
: "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-slate-600",
].join(" ")}
>
<span className="flex w-full items-center justify-between gap-1">
<span
className={[
"text-sm font-semibold",
active
? "text-indigo-700 dark:text-indigo-300"
: "text-slate-800 dark:text-slate-200",
].join(" ")}
>
{topic.label}
</span>
<span
aria-hidden="true"
className={[
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition-colors",
active
? "border-indigo-500 bg-indigo-500 text-white dark:border-indigo-400 dark:bg-indigo-400"
: "border-slate-300 text-transparent dark:border-slate-600",
].join(" ")}
>
<svg
viewBox="0 0 24 24"
className="h-2.5 w-2.5"
fill="none"
stroke="currentColor"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 13l4 4L19 7" />
</svg>
</span>
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{topic.hint}
</span>
</button>
);
})}
</div>
</div>
{/* Email */}
<div className="mt-4">
<label
htmlFor={`${uid}-email`}
className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400"
>
Email address
</label>
<div className="relative mt-1.5">
<span
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500"
>
<svg
viewBox="0 0 24 24"
className="h-[18px] w-[18px]"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="5" width="18" height="14" rx="2.5" />
<path d="m3.5 7 8.5 6 8.5-6" />
</svg>
</span>
<input
ref={emailRef}
id={`${uid}-email`}
name="email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@studio.com"
value={email}
onChange={onEmailChange}
onBlur={() => setTouched(true)}
aria-invalid={emailInvalid}
aria-describedby={emailInvalid ? errId : undefined}
className={[
"w-full rounded-xl border bg-white py-2.5 pl-10 pr-3 text-sm text-slate-900 outline-none transition-colors placeholder:text-slate-400 focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:bg-slate-800/60 dark:text-white dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-900",
emailInvalid
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
: "border-slate-300 focus-visible:ring-indigo-500 dark:border-slate-700",
].join(" ")}
/>
</div>
<p
id={errId}
aria-live="polite"
className={[
"mt-1.5 text-xs text-rose-600 transition-opacity dark:text-rose-400",
emailInvalid ? "opacity-100" : "opacity-0",
].join(" ")}
>
{emailInvalid
? "Please enter a valid email address."
: " "}
</p>
</div>
{/* Submit */}
<button
type="submit"
disabled={status === "submitting"}
className="mt-1 flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 outline-none transition-all hover:from-indigo-500 hover:to-violet-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-slate-900"
>
{status === "submitting" ? (
<>
<svg
viewBox="0 0 24 24"
className="h-4 w-4 animate-spin"
fill="none"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeWidth={3}
className="opacity-25"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
/>
</svg>
Signing you up…
</>
) : (
<>
Subscribe free
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</>
)}
</button>
{/* Social proof + trust */}
<div className="mt-4 flex items-center justify-center gap-3">
<div className="flex -space-x-2" aria-hidden="true">
{SUBSCRIBERS.map((s) => (
<span
key={s.initials}
className={[
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold text-white ring-2 ring-white dark:ring-slate-900",
s.tone,
].join(" ")}
>
{s.initials}
</span>
))}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Joining{" "}
<span className="font-semibold text-slate-700 dark:text-slate-300">
12,400+
</span>{" "}
builders
</p>
</div>
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-slate-400 dark:text-slate-500">
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="4" y="10" width="16" height="10" rx="2" />
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
No spam. Unsubscribe in one click.
</p>
</motion.form>
)}
</AnimatePresence>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}Dependencies
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 →
Inline Email Signup Bar
OriginalA wide banner that pairs a short heading with a single-line email field and subscribe button laid out side by side on desktop.

Boxed Newsletter Card
OriginalA centred, self-contained signup card with an icon badge, stacked email field and reassurance footer for dropping into a sidebar or narrow column.

Split Newsletter With Illustration
OriginalA two-column section combining copy and an inline email form with a gradient panel and hand-drawn SVG envelope illustration.

Contact Form With Details
OriginalA full contact section pairing email and location details with a name, email and message form on a bordered card.

Inline Newsletter
Originalinline newsletter bar

Card Newsletter
Originalnewsletter card

Split Image Newsletter
Originalnewsletter with image side

Gradient Newsletter
Originalgradient newsletter band

Footer Bar Newsletter
Originalsticky footer newsletter bar

Benefits Newsletter
Originalnewsletter with benefit bullets

Dark Newsletter
Originaldark newsletter section

Floating Newsletter
Originalfloating newsletter widget

