Retour au catalogue
Waitlist Animated
Section waitlist premium avec barre de progression animée (scaleX whileInView), formulaire email, animation de chargement, burst confetti au succès et compteur de position qui décompte en cascade.
waitlistcomplex Both Responsive a11y
boldelegantminimalsaasagencyuniversalcentered
Theme
"use client";
import { useRef, useState, useEffect, useMemo } from "react";
import { motion, useInView, AnimatePresence } from "framer-motion";
import { ArrowRight, Check } from "lucide-react";
interface WaitlistAnimatedProps {
title?: string;
subtitle?: string;
placeholder?: string;
ctaLabel?: string;
spotsTotal?: number;
spotsTaken?: number;
positionStart?: number;
}
const EASE = [0.16, 1, 0.3, 1] as const;
// Pre-computed confetti trajectories — stable across renders
const CONFETTI = [
{ x: -90, y: -100, color: "#818cf8", size: 9, delay: 0 },
{ x: 70, y: -120, color: "#34d399", size: 7, delay: 0.06 },
{ x: -60, y: -80, color: "#f472b6", size: 8, delay: 0.04 },
{ x: 110, y: -90, color: "#fbbf24", size: 6, delay: 0.1 },
{ x: 40, y: -130, color: "#818cf8", size: 10, delay: 0.02 },
{ x: -110, y: -70, color: "#60a5fa", size: 7, delay: 0.08 },
{ x: 80, y: -60, color: "#f472b6", size: 6, delay: 0.05 },
{ x: -40, y: -115, color: "#34d399", size: 8, delay: 0.12 },
] as const;
function ConfettiDot({
x,
y,
color,
size,
delay,
}: {
x: number;
y: number;
color: string;
size: number;
delay: number;
}) {
return (
<motion.div
aria-hidden
initial={{ opacity: 1, x: 0, y: 0, scale: 1 }}
animate={{ opacity: 0, x, y, scale: 0 }}
transition={{ duration: 0.9, delay, ease: "easeOut" }}
style={{
position: "absolute",
width: size,
height: size,
borderRadius: "50%",
background: color,
top: "50%",
left: "50%",
marginLeft: -size / 2,
marginTop: -size / 2,
pointerEvents: "none",
}}
/>
);
}
function CountdownNumber({
from,
to,
}: {
from: number;
to: number;
}) {
const [display, setDisplay] = useState(from);
useEffect(() => {
const duration = 2200;
const steps = 40;
const interval = duration / steps;
const delta = from - to;
let step = 0;
const id = setInterval(() => {
step += 1;
const progress = step / steps;
// ease-out cubic
const eased = 1 - Math.pow(1 - progress, 3);
setDisplay(Math.round(from - delta * eased));
if (step >= steps) {
clearInterval(id);
setDisplay(to);
}
}, interval);
return () => clearInterval(id);
}, [from, to]);
return <span>{display.toLocaleString("en-US")}</span>;
}
type FormState = "idle" | "loading" | "success";
export default function WaitlistAnimated({
title = "The future is loading.",
subtitle = "Get early access, exclusive features, and founder pricing. Limited spots available.",
placeholder = "you@company.com",
ctaLabel = "Reserve my spot",
spotsTotal = 2000,
spotsTaken = 1340,
positionStart = 5800,
}: WaitlistAnimatedProps) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, margin: "-80px" });
const [email, setEmail] = useState("");
const [formState, setFormState] = useState<FormState>("idle");
const [userPosition, setUserPosition] = useState<number | null>(null);
const [showConfetti, setShowConfetti] = useState(false);
const pct = Math.min(spotsTaken / spotsTotal, 1);
const positionEnd = useMemo(
() => spotsTaken + 1,
[spotsTaken]
);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || formState !== "idle") return;
setFormState("loading");
// Simulate async signup
setTimeout(() => {
setFormState("success");
setUserPosition(positionEnd);
setShowConfetti(true);
setTimeout(() => setShowConfetti(false), 1200);
}, 1400);
}
return (
<section
style={{
position: "relative",
overflow: "hidden",
padding: "var(--section-padding-y, 5rem) 0",
}}
>
{/* Background gradient */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
background:
"radial-gradient(ellipse 80% 60% at 50% 0%, color-mix(in srgb, var(--color-accent) 10%, transparent) 0%, var(--color-background) 65%)",
pointerEvents: "none",
}}
/>
<div
ref={ref}
style={{
maxWidth: 600,
margin: "0 auto",
padding: "0 var(--container-padding-x)",
textAlign: "center",
position: "relative",
zIndex: 1,
}}
>
{/* Headline */}
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.65, ease: EASE }}
>
<p
style={{
display: "inline-block",
padding: "0.3rem 0.9rem",
borderRadius: "var(--radius-full)",
background:
"color-mix(in srgb, var(--color-accent) 10%, transparent)",
border:
"1px solid color-mix(in srgb, var(--color-accent) 25%, transparent)",
fontSize: "0.75rem",
fontWeight: 600,
letterSpacing: "0.07em",
textTransform: "uppercase",
color: "var(--color-accent)",
marginBottom: "1.5rem",
}}
>
Early access
</p>
<h2
style={{
fontFamily: "var(--font-sans)",
fontSize: "clamp(2rem, 4.5vw, 3.5rem)",
fontWeight: 700,
lineHeight: 1.08,
letterSpacing: "-0.03em",
color: "var(--color-foreground)",
marginBottom: "1rem",
}}
>
{title}
</h2>
<p
style={{
fontSize: "1.0625rem",
lineHeight: 1.7,
color: "var(--color-foreground-muted)",
maxWidth: 440,
margin: "0 auto 2.75rem",
}}
>
{subtitle}
</p>
</motion.div>
{/* Progress bar */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.55, delay: 0.18, ease: EASE }}
style={{ marginBottom: "2.5rem" }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.8125rem",
fontWeight: 500,
color: "var(--color-foreground-muted)",
marginBottom: "0.6rem",
}}
>
<span>
<strong style={{ color: "var(--color-foreground)" }}>
{spotsTaken.toLocaleString("en-US")}
</strong>{" "}
spots reserved
</span>
<span>
{Math.round(pct * 100)}% full
</span>
</div>
<div
style={{
height: 6,
borderRadius: "var(--radius-full)",
background:
"color-mix(in srgb, var(--color-border) 60%, transparent)",
overflow: "hidden",
}}
>
<motion.div
initial={{ scaleX: 0 }}
animate={isInView ? { scaleX: pct } : {}}
transition={{ duration: 1.4, delay: 0.35, ease: EASE }}
style={{
height: "100%",
borderRadius: "var(--radius-full)",
background:
"linear-gradient(90deg, var(--color-accent) 0%, color-mix(in srgb, var(--color-accent) 60%, white) 100%)",
transformOrigin: "left",
}}
/>
</div>
<p
style={{
fontSize: "0.75rem",
color: "var(--color-foreground-muted)",
marginTop: "0.5rem",
opacity: 0.7,
}}
>
Only {(spotsTotal - spotsTaken).toLocaleString("en-US")} spots remaining
</p>
</motion.div>
{/* Form or success */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.3, ease: EASE }}
style={{ position: "relative" }}
>
<AnimatePresence mode="wait">
{formState !== "success" ? (
<motion.form
key="form"
initial={{ opacity: 1 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.3 }}
onSubmit={handleSubmit}
style={{ display: "flex", gap: "0.625rem", flexWrap: "wrap" }}
>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={placeholder}
style={{
flex: 1,
minWidth: 220,
padding: "0.9375rem 1.25rem",
borderRadius: "var(--radius-full)",
border: "1px solid var(--color-border)",
background: "var(--color-background-card)",
color: "var(--color-foreground)",
fontSize: "0.9375rem",
outline: "none",
}}
/>
<motion.button
type="submit"
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
disabled={formState === "loading"}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.9375rem 1.75rem",
borderRadius: "var(--radius-full)",
background: "var(--color-accent)",
color: "var(--color-background)",
fontWeight: 600,
fontSize: "0.9375rem",
border: "none",
cursor: formState === "loading" ? "wait" : "pointer",
whiteSpace: "nowrap",
boxShadow:
"0 4px 16px color-mix(in srgb, var(--color-accent) 35%, transparent)",
opacity: formState === "loading" ? 0.75 : 1,
transition: "opacity 0.2s",
}}
>
{formState === "loading" ? (
<>
<motion.span
animate={{ rotate: 360 }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
style={{
display: "inline-block",
width: 16,
height: 16,
borderRadius: "50%",
border: "2px solid transparent",
borderTopColor: "var(--color-background)",
borderRightColor: "var(--color-background)",
}}
/>
Joining…
</>
) : (
<>
{ctaLabel}
<ArrowRight style={{ width: 15, height: 15 }} />
</>
)}
</motion.button>
</motion.form>
) : (
<motion.div
key="success"
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.55, ease: EASE }}
style={{
position: "relative",
padding: "2rem 2.5rem",
borderRadius: "var(--radius-xl, 1.25rem)",
border:
"1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)",
background:
"color-mix(in srgb, var(--color-accent) 6%, var(--color-background-card))",
}}
>
{/* Confetti burst */}
{showConfetti &&
CONFETTI.map((c, i) => (
<ConfettiDot key={i} {...c} />
))}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.4, delay: 0.1, ease: EASE }}
style={{
width: 48,
height: 48,
borderRadius: "50%",
background:
"color-mix(in srgb, var(--color-accent) 15%, transparent)",
border:
"2px solid color-mix(in srgb, var(--color-accent) 40%, transparent)",
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto 1.25rem",
}}
>
<Check style={{ width: 22, height: 22, color: "var(--color-accent)" }} />
</motion.div>
<p
style={{
fontWeight: 700,
fontSize: "1.125rem",
color: "var(--color-foreground)",
marginBottom: "0.5rem",
}}
>
You're on the list!
</p>
<p
style={{
fontSize: "0.9375rem",
color: "var(--color-foreground-muted)",
marginBottom: userPosition ? "1.25rem" : 0,
}}
>
We'll reach out as soon as your spot opens up.
</p>
{userPosition && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3, ease: EASE }}
style={{
display: "inline-flex",
alignItems: "baseline",
gap: "0.4rem",
padding: "0.5rem 1.25rem",
borderRadius: "var(--radius-full)",
background:
"color-mix(in srgb, var(--color-accent) 10%, transparent)",
border:
"1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)",
}}
>
<span
style={{
fontSize: "0.8125rem",
color: "var(--color-foreground-muted)",
}}
>
Your position:
</span>
<span
style={{
fontWeight: 700,
fontSize: "1.125rem",
color: "var(--color-accent)",
fontVariantNumeric: "tabular-nums",
}}
>
#<CountdownNumber from={positionStart} to={userPosition} />
</span>
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Social proof micro-text */}
<motion.p
initial={{ opacity: 0 }}
animate={isInView ? { opacity: 1 } : {}}
transition={{ duration: 0.5, delay: 0.55 }}
style={{
marginTop: "1.5rem",
fontSize: "0.8125rem",
color: "var(--color-foreground-muted)",
opacity: 0.65,
}}
>
No spam. No credit card. Cancel anytime.
</motion.p>
</div>
</section>
);
}