Subscribe Card Form
Original · freesubscribe card with benefits
byWeb InnoventixReact + Tailwind
formsubscribecardforms
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/form-subscribe-card.jsonform-subscribe-card.tsx
"use client";
import {
useEffect,
useId,
useRef,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "idle" | "loading" | "error" | "success";
type Cadence = "weekly" | "monthly";
interface Benefit {
id: string;
title: string;
detail: string;
icon: ReactNode;
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const iconStroke = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
const BENEFITS: Benefit[] = [
{
id: "essay",
title: "One deep-dive every Tuesday",
detail: "A single 6-minute essay — no digest, no 40-link data dump.",
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true" {...iconStroke}>
<path d="M6 3.5h7l5 5v12H6z" />
<path d="M13 3.5v5h5" />
<path d="M9 13h6M9 16.5h4" />
</svg>
),
},
{
id: "teardown",
title: "Teardowns of shipped UIs",
detail: "Annotated breakdowns of production flows worth stealing from.",
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true" {...iconStroke}>
<rect x="3.5" y="4.5" width="17" height="13" rx="1.5" />
<path d="M3.5 8.5h17M7 11.5h5" />
<path d="M9 21h6M12 17.5V21" />
</svg>
),
},
{
id: "tool",
title: "A tested tool, every issue",
detail: "One library or utility we actually shipped with — not hype.",
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true" {...iconStroke}>
<path d="M14.5 6.5a3.5 3.5 0 0 0-4.6 4.4L4 16.8 7.2 20l5.9-5.9a3.5 3.5 0 0 0 4.4-4.6l-2.2 2.2-2.1-.5-.5-2.1z" />
</svg>
),
},
{
id: "privacy",
title: "No spam, one-click out",
detail: "Unsubscribe in a single click. We never rent or sell your email.",
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true" {...iconStroke}>
<path d="M12 3.5 5 6.2v4.6c0 4.3 2.9 7.6 7 9.2 4.1-1.6 7-4.9 7-9.2V6.2z" />
<path d="m9 12 2.2 2.2L15.5 10" />
</svg>
),
},
];
const AVATARS: { initials: string; className: string }[] = [
{ initials: "AR", className: "bg-indigo-500" },
{ initials: "MK", className: "bg-emerald-500" },
{ initials: "JL", className: "bg-amber-500" },
{ initials: "SP", className: "bg-rose-500" },
];
export default function FormSubscribeCard() {
const reduceMotion = useReducedMotion();
const [email, setEmail] = useState("");
const [cadence, setCadence] = useState<Cadence>("weekly");
const [status, setStatus] = useState<Status>("idle");
const [submitted, setSubmitted] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fieldId = useId();
const emailId = `${fieldId}-email`;
const hintId = `${fieldId}-hint`;
const errorId = `${fieldId}-error`;
const legendId = `${fieldId}-legend`;
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
function handleChange(value: string) {
setEmail(value);
if (status === "error") setStatus("idle");
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (status === "loading") return;
const value = email.trim();
if (!EMAIL_PATTERN.test(value)) {
setStatus("error");
return;
}
setStatus("loading");
timerRef.current = setTimeout(
() => {
setSubmitted(value);
setStatus("success");
},
reduceMotion ? 0 : 850,
);
}
function reset() {
setEmail("");
setCadence("weekly");
setSubmitted("");
setStatus("idle");
}
const isError = status === "error";
const isLoading = status === "loading";
const cardEntrance = reduceMotion
? undefined
: {
initial: { opacity: 0, y: 26 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, amount: 0.3 },
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
};
return (
<section className="relative w-full overflow-hidden bg-slate-100 px-4 py-16 text-slate-900 sm:py-24 dark:bg-black dark:text-zinc-100">
<style>{`
@keyframes fsc-float {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -16px, 0) scale(1.05); }
}
@keyframes fsc-shimmer {
0% { background-position: -180% 0; }
100% { background-position: 180% 0; }
}
@keyframes fsc-pop {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.12); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fsc-ring {
0% { transform: scale(0.75); opacity: 0.6; }
100% { transform: scale(2.3); opacity: 0; }
}
@keyframes fsc-spin { to { transform: rotate(360deg); } }
.fsc-orb { animation: fsc-float 9s ease-in-out infinite; }
.fsc-orb-2 { animation: fsc-float 12s ease-in-out infinite; animation-delay: -4s; }
.fsc-shine {
background-image: linear-gradient(110deg, transparent 30%, rgba(255,255,255,0.55) 50%, transparent 70%);
background-size: 200% 100%;
animation: fsc-shimmer 4.5s linear infinite;
}
.fsc-pop { animation: fsc-pop 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.fsc-ring { animation: fsc-ring 1.3s ease-out forwards; }
.fsc-spin { animation: fsc-spin 0.7s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.fsc-orb, .fsc-orb-2, .fsc-shine, .fsc-pop, .fsc-ring, .fsc-spin {
animation: none !important;
}
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 [background-image:radial-gradient(circle_at_1px_1px,rgba(100,116,139,0.16)_1px,transparent_0)] [background-size:26px_26px] opacity-60 dark:opacity-30"
/>
<motion.div
{...cardEntrance}
className="relative mx-auto grid w-full max-w-5xl overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-[0_30px_80px_-30px_rgba(15,23,42,0.35)] lg:grid-cols-[1.05fr_1fr] dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-[0_30px_90px_-30px_rgba(0,0,0,0.8)]"
>
{/* Accent / pitch panel */}
<div className="relative isolate overflow-hidden bg-gradient-to-br from-indigo-600 via-violet-600 to-indigo-700 p-8 text-white sm:p-10">
<div
aria-hidden="true"
className="fsc-orb absolute -left-16 -top-20 h-56 w-56 rounded-full bg-violet-400/40 blur-3xl"
/>
<div
aria-hidden="true"
className="fsc-orb-2 absolute -bottom-24 right-0 h-64 w-64 rounded-full bg-sky-400/30 blur-3xl"
/>
<div className="relative">
<span className="relative inline-flex items-center gap-2 overflow-hidden rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-medium tracking-wide text-white/90 backdrop-blur">
<span
aria-hidden="true"
className="fsc-shine pointer-events-none absolute inset-0"
/>
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-300 opacity-80" />
</span>
<span className="relative">Weekly newsletter · since 2021</span>
</span>
<h2 className="mt-6 text-3xl font-semibold leading-tight tracking-tight sm:text-4xl">
Ship interfaces people
<span className="block bg-gradient-to-r from-white to-indigo-200 bg-clip-text text-transparent">
actually feel.
</span>
</h2>
<p className="mt-4 max-w-md text-sm leading-relaxed text-indigo-100/90 sm:text-base">
<span className="font-semibold text-white">The Interface</span> is a
Tuesday-morning email for engineers and designers who sweat the
details — motion, spacing, state, and the tiny decisions that
separate polished from good-enough.
</p>
<ul className="mt-8 space-y-4">
{BENEFITS.map((benefit) => (
<li key={benefit.id} className="flex gap-3.5">
<span className="mt-0.5 flex h-9 w-9 flex-none items-center justify-center rounded-xl bg-white/12 text-white ring-1 ring-inset ring-white/20">
<span className="h-5 w-5">{benefit.icon}</span>
</span>
<span>
<span className="block text-sm font-semibold text-white">
{benefit.title}
</span>
<span className="mt-0.5 block text-xs leading-relaxed text-indigo-100/80">
{benefit.detail}
</span>
</span>
</li>
))}
</ul>
<div className="mt-9 flex items-center gap-3">
<div className="flex -space-x-2">
{AVATARS.map((avatar) => (
<span
key={avatar.initials}
className={`flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-semibold text-white ring-2 ring-indigo-600 ${avatar.className}`}
>
{avatar.initials}
</span>
))}
</div>
<p className="text-xs text-indigo-100/85">
Join <span className="font-semibold text-white">14,200+</span>{" "}
builders reading each week.
</p>
</div>
</div>
</div>
{/* Form panel */}
<div className="p-8 sm:p-10">
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div
key="success"
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? undefined : { opacity: 0, y: -12 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="flex h-full flex-col justify-center"
>
<div className="relative mx-auto flex h-16 w-16 items-center justify-center">
<span
aria-hidden="true"
className="fsc-ring absolute inset-0 rounded-full bg-emerald-400/40"
/>
<span className="fsc-pop relative flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500 text-white shadow-lg shadow-emerald-500/30">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-8 w-8"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m5 12.5 4.5 4.5L19 7.5" />
</svg>
</span>
</div>
<h3 className="mt-6 text-center text-2xl font-semibold tracking-tight text-slate-900 dark:text-zinc-50">
You’re in.
</h3>
<p className="mx-auto mt-2 max-w-sm text-center text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
We sent a confirmation link to{" "}
<span className="font-medium text-slate-900 dark:text-zinc-100">
{submitted}
</span>
. Click it to lock in your first{" "}
{cadence === "weekly" ? "weekly" : "monthly"} issue.
</p>
<button
type="button"
onClick={reset}
className="mx-auto mt-7 inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-indigo-600 transition-colors hover:text-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-zinc-950"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
{...iconStroke}
>
<path d="M9 5 4 10l5 5M4 10h11a5 5 0 0 1 0 10h-3" />
</svg>
Use a different email
</button>
</motion.div>
) : (
<motion.form
key="form"
noValidate
onSubmit={handleSubmit}
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? undefined : { opacity: 0, y: -12 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="flex h-full flex-col justify-center"
>
<h3 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-zinc-50">
Get the next issue
</h3>
<p className="mt-1.5 text-sm text-slate-600 dark:text-zinc-400">
Free forever. The first issue lands next Tuesday.
</p>
<div className="mt-6">
<label
htmlFor={emailId}
className="block text-sm font-medium text-slate-800 dark:text-zinc-200"
>
Email address
</label>
<div className="relative mt-1.5">
<span
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 dark:text-zinc-500"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
{...iconStroke}
>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3.5 7 8.5 6 8.5-6" />
</svg>
</span>
<input
id={emailId}
name="email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@studio.com"
value={email}
onChange={(event) => handleChange(event.target.value)}
aria-invalid={isError}
aria-describedby={isError ? errorId : hintId}
className={`w-full rounded-xl border bg-slate-50 py-3 pl-11 pr-3.5 text-sm text-slate-900 shadow-sm outline-none transition-colors placeholder:text-slate-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:ring-offset-zinc-950 ${
isError
? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
: "border-slate-200 focus-visible:ring-indigo-500 dark:border-zinc-700"
}`}
/>
</div>
{isError ? (
<p
id={errorId}
role="alert"
className="mt-2 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4 flex-none"
{...iconStroke}
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7.5v5M12 16h.01" />
</svg>
Enter a valid email so we know where to send it.
</p>
) : (
<p
id={hintId}
className="mt-2 text-xs text-slate-500 dark:text-zinc-500"
>
We’ll only email you the newsletter. Nothing else.
</p>
)}
</div>
<fieldset className="mt-5">
<legend
id={legendId}
className="text-sm font-medium text-slate-800 dark:text-zinc-200"
>
Delivery cadence
</legend>
<div
className="mt-1.5 grid grid-cols-2 gap-2.5"
role="radiogroup"
aria-labelledby={legendId}
>
{(
[
{ value: "weekly", label: "Weekly", sub: "Every Tuesday" },
{
value: "monthly",
label: "Monthly",
sub: "Best-of recap",
},
] as { value: Cadence; label: string; sub: string }[]
).map((option) => (
<label
key={option.value}
className="group relative flex cursor-pointer flex-col rounded-xl border border-slate-200 bg-slate-50 p-3 transition-colors has-[:checked]:border-indigo-500 has-[:checked]:bg-indigo-50 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-indigo-500 has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-white dark:border-zinc-700 dark:bg-zinc-900 dark:has-[:checked]:border-indigo-400 dark:has-[:checked]:bg-indigo-500/10 dark:has-[:focus-visible]:ring-offset-zinc-950"
>
<input
type="radio"
name="cadence"
value={option.value}
checked={cadence === option.value}
onChange={() => setCadence(option.value)}
className="sr-only"
/>
<span className="flex items-center justify-between">
<span className="text-sm font-semibold text-slate-900 dark:text-zinc-100">
{option.label}
</span>
<span
aria-hidden="true"
className="flex h-4 w-4 items-center justify-center rounded-full border border-slate-300 transition-colors group-has-[:checked]:border-indigo-500 group-has-[:checked]:bg-indigo-500 dark:border-zinc-600 dark:group-has-[:checked]:border-indigo-400 dark:group-has-[:checked]:bg-indigo-400"
>
<span className="h-1.5 w-1.5 rounded-full bg-white opacity-0 transition-opacity group-has-[:checked]:opacity-100" />
</span>
</span>
<span className="mt-0.5 text-xs text-slate-500 dark:text-zinc-400">
{option.sub}
</span>
</label>
))}
</div>
</fieldset>
<button
type="submit"
disabled={isLoading}
className="mt-6 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 transition-[background-color,transform] hover:bg-indigo-500 active:scale-[0.99] focus: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:focus-visible:ring-offset-zinc-950"
>
{isLoading ? (
<>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="fsc-spin h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
Subscribing…
</>
) : (
<>
Subscribe free
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</>
)}
</button>
<p className="mt-4 text-center text-xs leading-relaxed text-slate-500 dark:text-zinc-500">
By subscribing you agree to receive The Interface. Unsubscribe
any time — one click, no questions.
</p>
</motion.form>
)}
</AnimatePresence>
</div>
</motion.div>
</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 →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

