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>
);
}