Web InnoventixFreeCode

Subscribe Card Form

Original · free

subscribe card with benefits

byWeb InnoventixReact + Tailwind
formsubscribecardforms
Open

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.json
form-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&rsquo;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&rsquo;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 quote

Similar components

Browse all →