Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
import { AlertCircle, ArrowRight, Loader2, Mail } from "lucide-react";
import { cn } from "@/lib/utils";

export interface CtaNewsletterProps {
  eyebrow?: string;
  headline?: string;
  description?: string;
  placeholder?: string;
  buttonLabel?: string;
  successTitle?: string;
  successMessage?: string;
  /** Initials shown in the social-proof avatar stack. */
  avatars?: string[];
  proof?: string;
  /** Receives the email. Throw / reject to show an error message. */
  onSubscribe?: (email: string) => void | Promise<void>;
  className?: string;
}

const EASE = [0.22, 1, 0.36, 1] as const;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

const BLOBS = [
  { c: "bg-violet-400/40 dark:bg-violet-600/30", s: "size-72 sm:size-96", pos: "left-[-6%] top-[-10%]", x: [0, 60, -20, 0], y: [0, 40, 80, 0], d: 16 },
  { c: "bg-sky-300/40 dark:bg-sky-500/25", s: "size-64 sm:size-80", pos: "right-[-4%] top-[10%]", x: [0, -70, 20, 0], y: [0, 60, -20, 0], d: 19 },
  { c: "bg-pink-300/40 dark:bg-pink-500/25", s: "size-60 sm:size-72", pos: "left-[30%] bottom-[-20%]", x: [0, 50, -60, 0], y: [0, -50, -10, 0], d: 22 },
  { c: "bg-amber-200/50 dark:bg-amber-500/15", s: "size-48 sm:size-64", pos: "right-[25%] bottom-[-10%]", x: [0, -40, 30, 0], y: [0, -30, 40, 0], d: 18 },
] as const;

const AVATAR_COLORS = ["bg-violet-500", "bg-sky-500", "bg-pink-500", "bg-amber-500", "bg-emerald-500"];

export function CtaNewsletter({
  eyebrow = "The Lumen Letter",
  headline = "Ideas worth shipping, every other Tuesday",
  description = "One short email with product teardowns, growth experiments and the tools we're using. No fluff, ever.",
  placeholder = "Enter your email",
  buttonLabel = "Subscribe",
  successTitle = "You're subscribed!",
  successMessage = "Check your inbox to confirm — your first issue lands next Tuesday.",
  avatars = ["JL", "MK", "AS", "RD", "TN"],
  proof = "Join 12,400+ founders and designers",
  onSubscribe,
  className,
}: CtaNewsletterProps) {
  const reduce = useReducedMotion();
  const id = React.useId();
  const inputRef = React.useRef<HTMLInputElement>(null);
  const [email, setEmail] = React.useState("");
  const [status, setStatus] = React.useState<"idle" | "loading" | "success">("idle");
  const [error, setError] = React.useState<string | null>(null);
  const rowRef = React.useRef<HTMLDivElement>(null);

  const fail = (msg: string) => {
    setError(msg);
    if (rowRef.current && !reduce) animate(rowRef.current, { x: [0, -8, 8, -5, 5, 0] }, { duration: 0.4 });
    inputRef.current?.focus();
  };

  const submit = async (e: React.FormEvent) => {
    e.preventDefault();
    const value = email.trim();
    if (!value) return fail("Please enter your email address.");
    if (!EMAIL.test(value)) return fail("That doesn't look like a valid email.");
    setError(null);
    setStatus("loading");
    try {
      await (onSubscribe ? onSubscribe(value) : new Promise((r) => setTimeout(r, 1000)));
      setStatus("success");
    } catch (err) {
      setStatus("idle");
      fail(err instanceof Error ? err.message : "Something went wrong. Please try again.");
    }
  };

  return (
    <section className={cn("relative isolate w-full overflow-hidden bg-background px-5 py-20 text-foreground sm:px-8 sm:py-28", className)}>
      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10 [mask-image:linear-gradient(to_bottom,transparent,#000_18%,#000_82%,transparent)]">
        {BLOBS.map((b, i) => (
          <motion.div
            key={i}
            className={cn("absolute rounded-full blur-3xl", b.c, b.s, b.pos)}
            animate={reduce ? undefined : { x: [...b.x], y: [...b.y] }}
            transition={{ duration: b.d, repeat: Infinity, ease: "easeInOut" }}
          />
        ))}
      </div>

      <div className="mx-auto max-w-2xl rounded-3xl border border-foreground/10 bg-background/60 p-7 text-center shadow-xl shadow-black/5 backdrop-blur-2xl sm:p-12 dark:bg-background/50 dark:shadow-black/30">
        <AnimatePresence mode="wait" initial={false}>
          {status === "success" ? (
            <motion.div
              key="success"
              initial={{ opacity: 0, scale: 0.96 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.35, ease: EASE }}
              role="status"
              className="flex flex-col items-center py-4"
            >
              <SuccessCheck reduce={!!reduce} />
              <h2 className="mt-6 text-2xl font-semibold tracking-tight sm:text-3xl">{successTitle}</h2>
              <p className="mt-3 max-w-sm text-muted-foreground">{successMessage}</p>
              <p className="mt-2 text-sm font-medium">{email}</p>
              <button
                type="button"
                onClick={() => (setStatus("idle"), setEmail(""))}
                className="mt-6 rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground underline-offset-4 transition hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                Use a different email
              </button>
            </motion.div>
          ) : (
            <motion.div key="form" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, scale: 0.98 }} transition={{ duration: 0.25 }}>
              <span className="mx-auto grid size-12 place-items-center rounded-2xl border bg-card shadow-sm">
                <Mail className="size-5 text-primary" aria-hidden />
              </span>
              <p className="mt-5 text-sm font-medium text-primary">{eyebrow}</p>
              <h2 className="mt-2 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">{headline}</h2>
              <p className="mx-auto mt-4 max-w-md text-pretty text-muted-foreground">{description}</p>

              <form onSubmit={submit} noValidate className="mx-auto mt-8 max-w-md text-left">
                <label htmlFor={id} className="sr-only">
                  Email address
                </label>
                <div ref={rowRef} className="flex flex-col gap-2 sm:flex-row">
                  <input
                    ref={inputRef}
                    id={id}
                    type="email"
                    inputMode="email"
                    autoComplete="email"
                    value={email}
                    onChange={(e) => (setEmail(e.target.value), error && setError(null))}
                    placeholder={placeholder}
                    aria-invalid={!!error}
                    aria-describedby={`${id}-err`}
                    disabled={status === "loading"}
                    className={cn(
                      "h-12 w-full min-w-0 rounded-xl sm:w-auto sm:flex-1 border bg-card px-4 text-sm shadow-sm outline-none transition placeholder:text-muted-foreground focus:border-primary/60 focus:ring-4 focus:ring-primary/15",
                      error && "border-destructive/70 focus:border-destructive focus:ring-destructive/15",
                    )}
                  />
                  <button
                    type="submit"
                    disabled={status === "loading"}
                    className="group inline-flex h-12 items-center justify-center gap-2 rounded-xl bg-foreground px-6 text-sm font-semibold text-background shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-70"
                  >
                    {status === "loading" ? (
                      <Loader2 className="size-4 animate-spin" aria-hidden />
                    ) : (
                      <>
                        {buttonLabel}
                        <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
                      </>
                    )}
                    {status === "loading" && <span className="sr-only">Subscribing…</span>}
                  </button>
                </div>
                <div id={`${id}-err`} aria-live="polite" className="min-h-6 pt-2">
                  <AnimatePresence>
                    {error && (
                      <motion.p
                        initial={{ opacity: 0, y: -4 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={{ opacity: 0 }}
                        className="flex items-center gap-1.5 text-sm text-destructive"
                      >
                        <AlertCircle className="size-4 shrink-0" aria-hidden />
                        {error}
                      </motion.p>
                    )}
                  </AnimatePresence>
                </div>
              </form>

              {avatars.length > 0 && (
                <div className="mt-4 flex flex-col items-center justify-center gap-3 sm:flex-row">
                  <div className="flex -space-x-2" aria-hidden>
                    {avatars.slice(0, 5).map((a, i) => (
                      <span key={a + i} className={cn("grid size-8 place-items-center rounded-full text-[11px] font-semibold text-white ring-2 ring-background", AVATAR_COLORS[i % AVATAR_COLORS.length])}>
                        {a}
                      </span>
                    ))}
                  </div>
                  <p className="text-sm text-muted-foreground">{proof}</p>
                </div>
              )}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

function SuccessCheck({ reduce }: { reduce: boolean }) {
  const t = (delay: number, duration: number) => (reduce ? { duration: 0 } : { delay, duration, ease: EASE });
  return (
    <div className="relative grid size-20 place-items-center">
      <motion.span
        aria-hidden
        className="absolute inset-0 rounded-full bg-emerald-500/15"
        initial={{ scale: 0.4, opacity: 0 }}
        animate={{ scale: [0.4, 1.25, 1], opacity: 1 }}
        transition={t(0.05, 0.6)}
      />
      <svg viewBox="0 0 52 52" className="relative size-16 text-emerald-600 dark:text-emerald-400" aria-hidden>
        <motion.circle
          cx="26"
          cy="26"
          r="23"
          fill="none"
          stroke="currentColor"
          strokeWidth="3"
          strokeLinecap="round"
          initial={{ pathLength: 0, rotate: -90 }}
          animate={{ pathLength: 1, rotate: -90 }}
          transition={t(0.1, 0.6)}
          style={{ originX: "50%", originY: "50%" }}
        />
        <motion.path
          d="M15 27 l7.5 7.5 L37.5 19"
          fill="none"
          stroke="currentColor"
          strokeWidth="3.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          initial={{ pathLength: 0 }}
          animate={{ pathLength: 1 }}
          transition={t(0.6, 0.45)}
        />
      </svg>
    </div>
  );
}

More in Call to Action

View all →