Web InnoventixFreeCode

Newsletter Modal

Original · free

newsletter capture modal

byWeb InnoventixReact + Tailwind
modalnewslettermodals
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/modal-newsletter.json
modal-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 quote

Similar components

Browse all →