Retour au catalogue

Newsletter Confetti

Newsletter avec explosion de confettis animes au submit. Particules motion.div avec rotation, opacity et trajectoire aleatoires. Le texte change en message de confirmation.

newslettercomplex Both Responsive a11y
playfulboldminimalsaasecommerceuniversalcentered
Theme
"use client";

import { useState, useCallback, useRef } from "react";
import { motion, AnimatePresence, useInView } from "framer-motion";
import { Send, Check } from "lucide-react";

interface NewsletterConfettiProps {
  title?: string;
  description?: string;
  placeholder?: string;
  submitLabel?: string;
  successMessage?: string;
}

const EASE = [0.16, 1, 0.3, 1] as const;
const CONFETTI_COLORS = [
  "var(--color-accent)",
  "var(--color-foreground)",
  "var(--color-foreground-muted)",
  "var(--color-accent-hover, var(--color-accent))",
];

interface Particle {
  id: number;
  x: number;
  y: number;
  rotation: number;
  color: string;
  size: number;
  dx: number;
  dy: number;
}

function generateParticles(cx: number, cy: number): Particle[] {
  return Array.from({ length: 32 }, (_, i) => {
    const angle = (Math.PI * 2 * i) / 32 + (Math.random() - 0.5) * 0.8;
    const speed = 120 + Math.random() * 200;
    return {
      id: i,
      x: cx,
      y: cy,
      rotation: Math.random() * 360,
      color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
      size: 4 + Math.random() * 6,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed - 80,
    };
  });
}

export default function NewsletterConfetti({
  title = "Restez informe",
  description = "Inscrivez-vous a notre newsletter.",
  placeholder = "Votre email",
  submitLabel = "S'inscrire",
  successMessage = "Merci ! Vous etes inscrit.",
}: NewsletterConfettiProps) {
  const [submitted, setSubmitted] = useState(false);
  const [particles, setParticles] = useState<Particle[]>([]);
  const btnRef = useRef<HTMLButtonElement>(null);
  const sectionRef = useRef<HTMLElement>(null);
  const inView = useInView(sectionRef, { once: true, margin: "-80px" });

  const handleSubmit = useCallback(() => {
    if (submitted) return;
    const btn = btnRef.current;
    if (btn) {
      const rect = btn.getBoundingClientRect();
      const section = sectionRef.current?.getBoundingClientRect();
      const cx = rect.left + rect.width / 2 - (section?.left ?? 0);
      const cy = rect.top + rect.height / 2 - (section?.top ?? 0);
      setParticles(generateParticles(cx, cy));
    }
    setSubmitted(true);
  }, [submitted]);

  return (
    <section
      ref={sectionRef}
      style={{
        position: "relative",
        overflow: "hidden",
        padding: "var(--section-padding-y) 0",
        background: "var(--color-background)",
      }}
    >
      {/* Confetti */}
      <AnimatePresence>
        {particles.map((p) => (
          <motion.div
            key={p.id}
            initial={{ x: p.x, y: p.y, opacity: 1, rotate: 0, scale: 1 }}
            animate={{
              x: p.x + p.dx,
              y: p.y + p.dy + 180,
              opacity: 0,
              rotate: p.rotation,
              scale: 0.3,
            }}
            exit={{ opacity: 0 }}
            transition={{ duration: 1.2, ease: "easeOut" }}
            style={{
              position: "absolute",
              width: p.size,
              height: p.size,
              borderRadius: p.id % 3 === 0 ? "50%" : "2px",
              background: p.color,
              pointerEvents: "none",
              zIndex: 10,
            }}
          />
        ))}
      </AnimatePresence>

      <div
        style={{
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
          position: "relative",
          zIndex: 1,
        }}
      >
        <motion.div
          initial={{ opacity: 0, y: 30 }}
          animate={inView ? { opacity: 1, y: 0 } : {}}
          transition={{ duration: 0.6, ease: EASE }}
          style={{ maxWidth: "520px", margin: "0 auto", textAlign: "center" }}
        >
          <h2
            style={{
              fontFamily: "var(--font-sans)",
              fontSize: "clamp(1.75rem, 3vw, 2.5rem)",
              fontWeight: 700,
              color: "var(--color-foreground)",
              marginBottom: "0.75rem",
              letterSpacing: "-0.02em",
            }}
          >
            {title}
          </h2>
          <p
            style={{
              fontSize: "1rem",
              lineHeight: 1.6,
              color: "var(--color-foreground-muted)",
              marginBottom: "2rem",
            }}
          >
            {description}
          </p>

          <AnimatePresence mode="wait">
            {!submitted ? (
              <motion.div
                key="form"
                exit={{ opacity: 0, scale: 0.95 }}
                transition={{ duration: 0.25 }}
                style={{
                  display: "flex",
                  gap: "0.5rem",
                  maxWidth: "420px",
                  margin: "0 auto",
                }}
              >
                <input
                  type="email"
                  placeholder={placeholder}
                  style={{
                    flex: 1,
                    padding: "0.875rem 1.25rem",
                    borderRadius: "var(--radius-full)",
                    border: "1px solid var(--color-border)",
                    background: "var(--color-background-alt, var(--color-background))",
                    color: "var(--color-foreground)",
                    fontSize: "0.9375rem",
                    outline: "none",
                  }}
                />
                <button
                  ref={btnRef}
                  onClick={handleSubmit}
                  style={{
                    display: "inline-flex",
                    alignItems: "center",
                    gap: "6px",
                    padding: "0.875rem 1.5rem",
                    borderRadius: "var(--radius-full)",
                    border: "none",
                    background: "var(--color-accent)",
                    color: "var(--color-foreground)",
                    fontWeight: 600,
                    fontSize: "0.9375rem",
                    cursor: "pointer",
                    whiteSpace: "nowrap",
                  }}
                >
                  {submitLabel}
                  <Send style={{ width: 14, height: 14 }} />
                </button>
              </motion.div>
            ) : (
              <motion.div
                key="success"
                initial={{ opacity: 0, y: 10, scale: 0.95 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                transition={{ duration: 0.5, ease: EASE }}
                style={{
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  gap: "0.5rem",
                  color: "var(--color-accent)",
                  fontWeight: 600,
                  fontSize: "1.0625rem",
                }}
              >
                <Check style={{ width: 20, height: 20 }} />
                {successMessage}
              </motion.div>
            )}
          </AnimatePresence>
        </motion.div>
      </div>
    </section>
  );
}

Avis

Newsletter Confetti — React Newsletter Section — Incubator