Web InnoventixFreeCode

Newsletter Form

Original · free

inline newsletter subscribe form

byWeb InnoventixReact + Tailwind
formnewsletterforms
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-newsletter.json
form-newsletter.tsx
"use client";

import { useId, useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Status = "idle" | "loading" | "success" | "error";
type Cadence = "weekly" | "digest";

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

const CADENCES: ReadonlyArray<{ value: Cadence; label: string; hint: string }> = [
  { value: "weekly", label: "Every Tuesday", hint: "One idea a week" },
  { value: "digest", label: "Monthly digest", hint: "The best, batched" },
];

export default function NewsletterForm() {
  const reduce = useReducedMotion();

  const emailId = useId();
  const errorId = useId();
  const statusId = useId();
  const groupId = useId();

  const inputRef = useRef<HTMLInputElement>(null);

  const [email, setEmail] = useState("");
  const [cadence, setCadence] = useState<Cadence>("weekly");
  const [status, setStatus] = useState<Status>("idle");
  const [error, setError] = useState<string | null>(null);

  const busy = status === "loading";
  const done = status === "success";

  function announce(): string {
    if (status === "loading") return "Subscribing, please wait.";
    if (status === "success") return "You are subscribed. Check your inbox to confirm.";
    return "";
  }

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setEmail(e.target.value);
    if (error) setError(null);
    if (status === "error") setStatus("idle");
  }

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (busy || done) return;

    const value = email.trim();
    if (value.length === 0) {
      setStatus("error");
      setError("Enter your email address to subscribe.");
      inputRef.current?.focus();
      return;
    }
    if (!EMAIL_RE.test(value)) {
      setStatus("error");
      setError("That email doesn't look right — check for a typo.");
      inputRef.current?.focus();
      return;
    }

    setError(null);
    setStatus("loading");
    window.setTimeout(() => setStatus("success"), 1100);
  }

  function reset() {
    setStatus("idle");
    setEmail("");
    setError(null);
    window.requestAnimationFrame(() => inputRef.current?.focus());
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 sm:py-28 dark:bg-zinc-950">
      <style>{`
        @keyframes nlsub-shimmer {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(120%); }
        }
        @keyframes nlsub-spin {
          to { transform: rotate(360deg); }
        }
        @keyframes nlsub-draw {
          from { stroke-dashoffset: 26; }
          to { stroke-dashoffset: 0; }
        }
        @keyframes nlsub-pop {
          0% { transform: scale(0.7); opacity: 0; }
          60% { transform: scale(1.06); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        .nlsub-spin { animation: nlsub-spin 0.7s linear infinite; }
        .nlsub-shimmer { animation: nlsub-shimmer 2.6s ease-in-out infinite; }
        .nlsub-check { stroke-dasharray: 26; animation: nlsub-draw 0.5s ease-out 0.1s both; }
        .nlsub-badge { animation: nlsub-pop 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .nlsub-spin, .nlsub-shimmer, .nlsub-check, .nlsub-badge {
            animation: none !important;
          }
          .nlsub-check { stroke-dashoffset: 0; }
        }
      `}</style>

      {/* soft ambient backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 -z-10"
        style={{
          backgroundImage:
            "radial-gradient(60rem 30rem at 50% -10%, rgba(99,102,241,0.14), transparent 70%)",
        }}
      />

      <motion.div
        initial={reduce ? false : { opacity: 0, y: 22 }}
        whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
        viewport={{ once: true, amount: 0.4 }}
        transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
        className="relative mx-auto w-full max-w-2xl overflow-hidden rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-900/5 sm:p-10 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/40"
      >
        <div className="mb-7 flex items-start gap-4">
          <span
            aria-hidden="true"
            className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-indigo-600/10 text-indigo-600 ring-1 ring-inset ring-indigo-600/20 dark:bg-indigo-400/10 dark:text-indigo-300 dark:ring-indigo-400/20"
          >
            <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
              <path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h15A1.5 1.5 0 0 1 21 7.5v9a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 16.5z" />
              <path d="m4 7 8 6 8-6" />
            </svg>
          </span>
          <div>
            <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-300">
              The Interface
            </p>
            <h2 className="mt-1 text-2xl font-bold tracking-tight text-slate-900 sm:text-[1.7rem] dark:text-zinc-50">
              One well-made idea about building interfaces.
            </h2>
            <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
              A short, practical note on frontend craft — layout, motion, accessibility.
              Read by 12,000+ product engineers and designers.
            </p>
          </div>
        </div>

        <AnimatePresence mode="wait" initial={false}>
          {done ? (
            <motion.div
              key="success"
              initial={reduce ? false : { opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduce ? undefined : { opacity: 0, y: -8 }}
              transition={{ duration: 0.35, ease: "easeOut" }}
              className="rounded-2xl border border-emerald-500/30 bg-emerald-50 p-6 dark:border-emerald-400/25 dark:bg-emerald-500/10"
            >
              <div className="flex items-start gap-4">
                <span className="nlsub-badge grid h-11 w-11 shrink-0 place-items-center rounded-full bg-emerald-600 text-white">
                  <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <path className="nlsub-check" d="m5 12.5 4.2 4.2L19 7" />
                  </svg>
                </span>
                <div>
                  <p className="text-lg font-semibold text-emerald-900 dark:text-emerald-100">
                    You&rsquo;re on the list.
                  </p>
                  <p className="mt-1 text-sm leading-relaxed text-emerald-800/90 dark:text-emerald-200/80">
                    We sent a confirmation to{" "}
                    <span className="font-medium break-all">{email.trim()}</span>. Click the link
                    inside to lock in your{" "}
                    {cadence === "weekly" ? "weekly" : "monthly"} subscription.
                  </p>
                  <button
                    type="button"
                    onClick={reset}
                    className="mt-4 inline-flex items-center gap-1.5 rounded-lg text-sm font-semibold text-emerald-800 underline-offset-4 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-50 dark:text-emerald-200 dark:focus-visible:ring-offset-zinc-900"
                  >
                    Use a different email
                    <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>
                </div>
              </div>
            </motion.div>
          ) : (
            <motion.form
              key="form"
              initial={reduce ? false : { opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={reduce ? undefined : { opacity: 0 }}
              transition={{ duration: 0.25 }}
              onSubmit={handleSubmit}
              noValidate
            >
              <fieldset className="mb-5">
                <legend id={groupId} className="mb-2 block text-sm font-medium text-slate-700 dark:text-zinc-300">
                  How often should we write?
                </legend>
                <div role="presentation" className="grid grid-cols-2 gap-2 sm:gap-3">
                  {CADENCES.map((opt) => (
                    <label
                      key={opt.value}
                      className="group relative cursor-pointer select-none"
                    >
                      <input
                        type="radio"
                        name="cadence"
                        value={opt.value}
                        checked={cadence === opt.value}
                        onChange={() => setCadence(opt.value)}
                        disabled={busy}
                        className="peer sr-only"
                      />
                      <span className="block rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 transition peer-hover:border-slate-300 peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white peer-disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-800/60 dark:peer-hover:border-zinc-600 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-400/10 dark:peer-focus-visible:ring-offset-zinc-900">
                        <span className="flex items-center justify-between">
                          <span className="text-sm font-semibold text-slate-800 dark:text-zinc-100">
                            {opt.label}
                          </span>
                          <span
                            aria-hidden="true"
                            className="grid h-4 w-4 place-items-center rounded-full border border-slate-300 transition peer-checked:border-indigo-500 dark:border-zinc-600 dark:peer-checked:border-indigo-400"
                          >
                            <span className="h-2 w-2 scale-0 rounded-full bg-indigo-600 transition peer-checked:scale-100 dark:bg-indigo-400" />
                          </span>
                        </span>
                        <span className="mt-0.5 block text-xs text-slate-500 dark:text-zinc-400">
                          {opt.hint}
                        </span>
                      </span>
                    </label>
                  ))}
                </div>
              </fieldset>

              <label htmlFor={emailId} className="mb-2 block text-sm font-medium text-slate-700 dark:text-zinc-300">
                Email address
              </label>
              <div className="flex flex-col gap-3 sm:flex-row">
                <div className="relative flex-1">
                  <span aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-0 grid w-11 place-items-center text-slate-400 dark:text-zinc-500">
                    <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
                      <path d="M4 6h16v12H4z" />
                      <path d="m4 7 8 6 8-6" />
                    </svg>
                  </span>
                  <input
                    ref={inputRef}
                    id={emailId}
                    name="email"
                    type="email"
                    inputMode="email"
                    autoComplete="email"
                    placeholder="you@studio.dev"
                    value={email}
                    onChange={handleChange}
                    disabled={busy}
                    aria-invalid={status === "error"}
                    aria-describedby={error ? errorId : undefined}
                    className="w-full rounded-xl border border-slate-300 bg-white py-3 pl-11 pr-4 text-[0.95rem] text-slate-900 placeholder:text-slate-400 transition focus:border-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:opacity-60 aria-[invalid=true]:border-rose-400 aria-[invalid=true]:focus-visible:ring-rose-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:ring-offset-zinc-900 dark:aria-[invalid=true]:border-rose-500/70"
                  />
                </div>

                <button
                  type="submit"
                  disabled={busy}
                  className="group relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden rounded-xl bg-indigo-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 transition hover:bg-indigo-500 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-900"
                >
                  {!reduce && !busy ? (
                    <span
                      aria-hidden="true"
                      className="nlsub-shimmer absolute inset-y-0 -left-full w-1/2 skew-x-[-20deg] bg-white/20"
                    />
                  ) : null}
                  {busy ? (
                    <>
                      <svg viewBox="0 0 24 24" className="nlsub-spin h-4 w-4" fill="none" aria-hidden="true">
                        <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth={3} className="opacity-25" />
                        <path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeWidth={3} strokeLinecap="round" />
                      </svg>
                      <span className="relative">Subscribing…</span>
                    </>
                  ) : (
                    <>
                      <span className="relative">Subscribe</span>
                      <svg viewBox="0 0 24 24" className="relative h-4 w-4 transition group-hover:translate-x-0.5" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                        <path d="M5 12h14M13 6l6 6-6 6" />
                      </svg>
                    </>
                  )}
                </button>
              </div>

              <div className="mt-2.5 min-h-[1.25rem]">
                {error ? (
                  <p id={errorId} role="alert" className="flex items-center gap-1.5 text-sm text-rose-600 dark:text-rose-400">
                    <svg viewBox="0 0 24 24" className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                      <circle cx="12" cy="12" r="9" />
                      <path d="M12 8v5M12 16h.01" />
                    </svg>
                    {error}
                  </p>
                ) : (
                  <p className="text-xs text-slate-500 dark:text-zinc-500">
                    No spam, ever. Unsubscribe in one click.
                  </p>
                )}
              </div>
            </motion.form>
          )}
        </AnimatePresence>

        <p id={statusId} role="status" aria-live="polite" className="sr-only">
          {announce()}
        </p>
      </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 →