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

Avis

Waitlist Animated — React Waitlist Section — Incubator