Card Newsletter
Original · freenewsletter 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-card.json"use client";
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "idle" | "loading" | "success" | "error";
type Cadence = "weekly" | "monthly";
interface Topic {
id: string;
label: string;
hint: string;
}
const TOPICS: Topic[] = [
{ id: "systems", label: "Systems & Infra", hint: "Distributed systems, databases, ops" },
{ id: "ai", label: "AI / ML", hint: "Model tooling, evals, applied research" },
{ id: "webplatform", label: "Web Platform", hint: "Browsers, standards, rendering" },
{ id: "craft", label: "Engineering Craft", hint: "Reviews, architecture, career" },
];
const CADENCES: { id: Cadence; label: string; sub: string }[] = [
{ id: "weekly", label: "Weekly", sub: "Every Thursday" },
{ id: "monthly", label: "Monthly", sub: "First of the month" },
];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const EASE = [0.22, 1, 0.36, 1] as const;
export default function NewsxCard() {
const reduce = useReducedMotion();
const uid = useId();
const emailId = `${uid}-email`;
const errId = `${uid}-err`;
const consentId = `${uid}-consent`;
const topicsLabelId = `${uid}-topics`;
const [email, setEmail] = useState("");
const [topics, setTopics] = useState<string[]>(["systems", "ai"]);
const [cadence, setCadence] = useState<Cadence>("weekly");
const [consent, setConsent] = useState(false);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState<string>("");
const timerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
};
}, []);
function toggleTopic(id: string) {
setTopics((prev) =>
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
);
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (status === "loading") return;
const trimmed = email.trim();
if (!EMAIL_RE.test(trimmed)) {
setError("Enter a valid email address so we can reach you.");
setStatus("error");
return;
}
if (!consent) {
setError("Please agree to receive the newsletter before subscribing.");
setStatus("error");
return;
}
setError(null);
setStatus("loading");
timerRef.current = window.setTimeout(() => {
setSubmitted(trimmed);
setStatus("success");
}, 1100);
}
function reset() {
setStatus("idle");
setError(null);
setEmail("");
setConsent(false);
setSubmitted("");
}
const motionProps = reduce
? {}
: {
initial: { opacity: 0, y: 10 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -10 },
transition: { duration: 0.4, ease: EASE },
};
const isError = status === "error";
const loading = status === "loading";
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes newsx-drift {
0%, 100% { transform: translate3d(0,0,0) scale(1); }
50% { transform: translate3d(0,-14px,0) scale(1.06); }
}
@keyframes newsx-sheen {
0% { transform: translateX(-120%); }
100% { transform: translateX(220%); }
}
@keyframes newsx-blink {
0%, 45% { opacity: 1; }
55%, 100% { opacity: 0.25; }
}
.newsx-drift-a { animation: newsx-drift 11s ease-in-out infinite; }
.newsx-drift-b { animation: newsx-drift 14s ease-in-out infinite reverse; }
.newsx-blink { animation: newsx-blink 1.4s steps(1) infinite; }
@media (prefers-reduced-motion: reduce) {
.newsx-drift-a,
.newsx-drift-b,
.newsx-blink,
.newsx-sheen { animation: none !important; }
}
`}</style>
{/* Atmosphere */}
<div aria-hidden className="pointer-events-none absolute inset-0">
<div className="newsx-drift-a absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
<div className="newsx-drift-b absolute -bottom-28 -right-16 h-80 w-80 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20" />
</div>
<div className="relative mx-auto w-full max-w-xl">
<article className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-[0_20px_60px_-25px_rgba(30,27,75,0.35)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-[0_20px_60px_-25px_rgba(0,0,0,0.8)]">
{/* Top accent */}
<div
aria-hidden
className="h-1.5 w-full bg-gradient-to-r from-indigo-500 via-violet-500 to-sky-400"
/>
<div className="px-6 py-8 sm:px-9 sm:py-10">
{/* Masthead */}
<div className="flex items-start justify-between gap-4">
<div>
<span className="inline-flex items-center gap-2 rounded-full bg-indigo-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:bg-indigo-950/60 dark:text-indigo-300 dark:ring-indigo-800/70">
<span className="newsx-blink h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Issue 143 · Thursday
</span>
<h2 className="mt-4 font-serif text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
The Compile Step
</h2>
</div>
<div
aria-hidden
className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-slate-900 text-white ring-1 ring-slate-900/10 dark:bg-white dark:text-slate-900 dark:ring-white/10"
>
<svg viewBox="0 0 24 24" fill="none" className="h-6 w-6" aria-hidden>
<path
d="M4 7.5 12 3l8 4.5v9L12 21l-8-4.5v-9Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="m8.5 11 2.2 2.2L15.5 8.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
<p className="mt-4 max-w-md text-[15px] leading-relaxed text-slate-600 dark:text-slate-300">
A no-fluff engineering dispatch. One deep teardown, three sharp
links, and the tradeoffs behind them — written by people who ship.
</p>
{/* Stats */}
<dl className="mt-6 grid grid-cols-3 divide-x divide-slate-200 rounded-2xl border border-slate-200 bg-slate-50/70 dark:divide-slate-800 dark:border-slate-800 dark:bg-slate-950/40">
{[
{ k: "Readers", v: "24,180" },
{ k: "Open rate", v: "62%" },
{ k: "Read time", v: "6 min" },
].map((s) => (
<div key={s.k} className="px-3 py-3 text-center">
<dd className="font-serif text-xl font-semibold text-slate-900 dark:text-white">
{s.v}
</dd>
<dt className="mt-0.5 text-[11px] font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
{s.k}
</dt>
</div>
))}
</dl>
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div key="done" {...motionProps} className="mt-8">
<div className="flex flex-col items-center rounded-2xl border border-emerald-200 bg-emerald-50 px-6 py-8 text-center dark:border-emerald-900/60 dark:bg-emerald-950/40">
<span className="grid h-14 w-14 place-items-center rounded-full bg-emerald-500 text-white shadow-lg shadow-emerald-500/30">
<svg viewBox="0 0 24 24" fill="none" className="h-7 w-7" aria-hidden>
<path
d="m5 12.5 4.2 4.2L19 7"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<h3 className="mt-4 font-serif text-2xl font-semibold text-slate-900 dark:text-white">
You’re on the list
</h3>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-slate-600 dark:text-slate-300">
A confirmation is on its way to{" "}
<span className="font-semibold text-emerald-700 dark:text-emerald-300">
{submitted}
</span>
. Your first {cadence} issue lands with the next send.
</p>
<button
type="button"
onClick={reset}
className="mt-6 rounded-full px-5 py-2 text-sm font-semibold text-emerald-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-50 dark:text-emerald-300 dark:focus-visible:ring-offset-emerald-950"
>
Subscribe another email
</button>
</div>
</motion.div>
) : (
<motion.form
key="form"
{...motionProps}
onSubmit={handleSubmit}
noValidate
className="mt-8"
>
{/* Topics */}
<fieldset>
<legend
id={topicsLabelId}
className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Pick your tracks
</legend>
<div
role="group"
aria-labelledby={topicsLabelId}
className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2"
>
{TOPICS.map((t) => {
const active = topics.includes(t.id);
return (
<button
key={t.id}
type="button"
aria-pressed={active}
onClick={() => toggleTopic(t.id)}
className={`group flex items-start gap-3 rounded-xl border px-3.5 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
active
? "border-indigo-400 bg-indigo-50 dark:border-indigo-600 dark:bg-indigo-950/50"
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700 dark:hover:bg-slate-800/50"
}`}
>
<span
aria-hidden
className={`mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-colors ${
active
? "border-indigo-500 bg-indigo-500 text-white"
: "border-slate-300 bg-transparent text-transparent dark:border-slate-600"
}`}
>
<svg viewBox="0 0 24 24" fill="none" className="h-3.5 w-3.5" aria-hidden>
<path
d="m5 12.5 4.2 4.2L19 7"
stroke="currentColor"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-slate-900 dark:text-white">
{t.label}
</span>
<span className="mt-0.5 block text-xs leading-snug text-slate-500 dark:text-slate-400">
{t.hint}
</span>
</span>
</button>
);
})}
</div>
</fieldset>
{/* Cadence */}
<fieldset className="mt-6">
<legend className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Cadence
</legend>
<div
role="radiogroup"
aria-label="Delivery cadence"
className="mt-3 grid grid-cols-2 gap-2"
>
{CADENCES.map((c) => (
<label
key={c.id}
className="relative cursor-pointer"
>
<input
type="radio"
name={`${uid}-cadence`}
value={c.id}
checked={cadence === c.id}
onChange={() => setCadence(c.id)}
className="peer sr-only"
/>
<span className="flex flex-col rounded-xl border border-slate-200 bg-white px-4 py-3 text-slate-700 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-checked:text-indigo-900 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:peer-checked:border-indigo-500 dark:peer-checked:bg-indigo-950/50 dark:peer-checked:text-indigo-100 dark:peer-focus-visible:ring-offset-slate-900">
<span className="text-sm font-semibold">{c.label}</span>
<span className="text-xs text-slate-500 peer-checked:text-indigo-700 dark:text-slate-400">
{c.sub}
</span>
</span>
</label>
))}
</div>
</fieldset>
{/* Email */}
<div className="mt-6">
<label
htmlFor={emailId}
className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Email address
</label>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<div className="relative flex-1">
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 left-3.5 flex items-center text-slate-400 dark:text-slate-500"
>
<svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2.5"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="m4 7 8 6 8-6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<input
id={emailId}
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@yourdomain.dev"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (isError) {
setStatus("idle");
setError(null);
}
}}
aria-invalid={isError}
aria-describedby={error ? errId : undefined}
className={`w-full rounded-xl border bg-white py-3 pl-11 pr-3.5 text-[15px] text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-950 dark:text-white dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-900 ${
isError
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-700"
: "border-slate-300 focus-visible:ring-indigo-500 dark:border-slate-700"
}`}
/>
</div>
<button
type="submit"
disabled={loading}
className="relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden rounded-xl bg-slate-900 px-6 py-3 text-[15px] font-semibold text-white shadow-lg shadow-slate-900/20 transition-[transform,background-color] hover:bg-slate-800 active:scale-[0.98] focus-visible:outline-none 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-80 dark:bg-white dark:text-slate-900 dark:shadow-white/10 dark:hover:bg-slate-100 dark:focus-visible:ring-offset-slate-900"
>
{!reduce && !loading && (
<span
aria-hidden
className="newsx-sheen absolute inset-y-0 left-0 w-1/3 -skew-x-12 bg-gradient-to-r from-transparent via-white/25 to-transparent"
style={{ animation: "newsx-sheen 2.8s ease-in-out infinite" }}
/>
)}
{loading ? (
<>
<svg
viewBox="0 0 24 24"
fill="none"
className={`h-4 w-4 ${reduce ? "" : "animate-spin"}`}
aria-hidden
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2.5"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
<span className="relative">Joining…</span>
</>
) : (
<span className="relative">Subscribe</span>
)}
</button>
</div>
<p
id={errId}
role="alert"
aria-live="assertive"
className={`mt-2 min-h-[1.1rem] text-sm font-medium text-rose-600 dark:text-rose-400 ${
error ? "" : "sr-only"
}`}
>
{error}
</p>
</div>
{/* Consent */}
<label
htmlFor={consentId}
className="mt-4 flex cursor-pointer items-start gap-3 text-sm text-slate-600 dark:text-slate-300"
>
<input
id={consentId}
type="checkbox"
checked={consent}
onChange={(e) => {
setConsent(e.target.checked);
if (isError) {
setStatus("idle");
setError(null);
}
}}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-600 dark:bg-slate-950 dark:focus-visible:ring-offset-slate-900"
/>
<span className="leading-snug">
Send me The Compile Step and occasional product notes. No
spam, unsubscribe in one click.
</span>
</label>
<p className="mt-4 flex items-center gap-1.5 text-xs text-slate-400 dark:text-slate-500">
<svg viewBox="0 0 24 24" fill="none" className="h-3.5 w-3.5" aria-hidden>
<rect
x="5"
y="11"
width="14"
height="9"
rx="2"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="M8 11V8a4 4 0 1 1 8 0v3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
We store only your email. Read the privacy note.
</p>
</motion.form>
)}
</AnimatePresence>
</div>
</article>
</div>
</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

Split Image Newsletter
Originalnewsletter with image side

Gradient Newsletter
Originalgradient newsletter band

Modal Newsletter
Originalnewsletter popup card

Footer Bar Newsletter
Originalsticky footer newsletter bar

Benefits Newsletter
Originalnewsletter with benefit bullets

Dark Newsletter
Originaldark newsletter section

Floating Newsletter
Originalfloating newsletter widget

