Launch Countdown Hero with Flip Clock and Waitlist Dot Burst

A coming soon page in one section. Eight ivory split-flap cards count down days, hours, minutes and seconds on a deep blue background, and each change is a real two-part flip: the top half folds down and the bottom half swings into place with a small bounce. Below the clock is a single-field waitlist form. A valid email swaps the form for a confirmation card and throws forty round dots and two rings outward from the button. On phones the eight cards shrink to fit one row and the form stacks.

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";

/** Time left when the page loads, so the demo always has something to count. Swap for a real date (see the notes). */
const START_SECONDS = 12 * 86400 + 7 * 3600 + 42 * 60 + 19;
const AHEAD = 2480;
const people = [
  { initials: "AM", tone: "bg-rose-400" },
  { initials: "JT", tone: "bg-sky-400" },
  { initials: "RK", tone: "bg-emerald-400" },
  { initials: "LS", tone: "bg-violet-400" },
];
const burstColours = ["#fde047", "#ffffff", "#7dd3fc", "#fda4af", "#fde047", "#a5b4fc"];

const rnd = (i: number, salt: number) => {
  const x = Math.sin(i * 78.233 + salt * 12.9898) * 43758.5453;
  return x - Math.floor(x);
};
/** Forty round dots thrown outward from the button. No two share a path. */
const dots = Array.from({ length: 40 }, (_, i) => {
  const angle = (i / 40) * Math.PI * 2 + rnd(i, 1) * 0.5;
  const reach = 70 + rnd(i, 2) * 190;
  return {
    x: Math.cos(angle) * reach * 1.25,
    y: Math.sin(angle) * reach * 0.8 - 20,
    size: 5 + Math.round(rnd(i, 3) * 9),
    colour: burstColours[i % burstColours.length],
    delay: rnd(i, 4) * 0.08,
    time: 1 + rnd(i, 5) * 0.7,
  };
});

/** Sixty dots on an oval track around the clock: one for each second of the minute. */
const track = Array.from({ length: 60 }, (_, i) => {
  const a = (i / 60) * Math.PI * 2 - Math.PI / 2;
  return { x: Math.round((500 + Math.cos(a) * 486) * 10) / 10, y: Math.round((150 + Math.sin(a) * 136) * 10) / 10 };
});

const half = "absolute inset-x-0 h-1/2 overflow-hidden";
const face = "absolute inset-x-0 flex h-[200%] items-center justify-center";

function FlipDigit({ value, reduce }: { value: string; reduce: boolean }) {
  const [s, setS] = useState({ from: value, to: value, n: 0 });
  if (value !== s.to) setS({ from: s.to, to: value, n: s.n + 1 });
  const flipping = s.n > 0 && !reduce;
  return (
    <span className="relative block h-14 w-9 text-4xl font-semibold tabular-nums text-blue-950 drop-shadow-xl sm:h-24 sm:w-16 sm:text-6xl lg:h-28 lg:w-20 lg:text-7xl" style={{ perspective: "420px" }}>
      <span className={`${half} top-0 rounded-t-lg bg-amber-50 sm:rounded-t-xl`}>
        <span className={`${face} top-0`}>{s.to}</span>
      </span>
      <span className={`${half} bottom-0 rounded-b-lg bg-amber-100 sm:rounded-b-xl`}>
        <span className={`${face} bottom-0`}>{flipping ? s.from : s.to}</span>
      </span>
      {flipping && (
        <span key={`t${s.n}`} className={`lch-flap-top ${half} top-0 rounded-t-lg bg-amber-50 sm:rounded-t-xl`}>
          <span className={`${face} top-0`}>{s.from}</span>
        </span>
      )}
      {flipping && (
        <span key={`b${s.n}`} className={`lch-flap-bottom ${half} bottom-0 rounded-b-lg bg-amber-100 sm:rounded-b-xl`}>
          <span className={`${face} bottom-0`}>{s.to}</span>
        </span>
      )}
      <span className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-blue-950/40" />
    </span>
  );
}

export default function LaunchCountdownHeroWithFlipClockAndWaitlistDotBurst() {
  const reduce = !!useReducedMotion();
  const [left, setLeft] = useState(START_SECONDS);
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");
  const [joined, setJoined] = useState(false);
  const [bursts, setBursts] = useState(0);
  const input = useRef<HTMLInputElement>(null);
  const ref = useRef<HTMLElement>(null);
  const [onScreen, setOnScreen] = useState(true);

  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting), { threshold: 0.05 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  // Count from the moment the page opened, so the clock stays right after the tab has been in the background.
  const opened = useRef(0);
  useEffect(() => {
    if (!onScreen) return;
    if (!opened.current) opened.current = Date.now();
    const id = setInterval(() => {
      if (!document.hidden) setLeft(Math.max(0, START_SECONDS - Math.floor((Date.now() - opened.current) / 1000)));
    }, 250);
    return () => clearInterval(id);
  }, [onScreen]);

  const two = (n: number) => String(n).padStart(2, "0");
  const d = Math.floor(left / 86400);
  const h = Math.floor((left % 86400) / 3600);
  const m = Math.floor((left % 3600) / 60);
  const units = [
    { label: "Days", value: two(d) },
    { label: "Hours", value: two(h) },
    { label: "Minutes", value: two(m) },
    { label: "Seconds", value: two(left % 60) },
  ];

  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
      setError("Enter an email address like [email protected].");
      input.current?.focus();
      return;
    }
    setError("");
    setJoined(true);
    setBursts((b) => b + 1);
  };

  const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-300 focus-visible:ring-offset-2 focus-visible:ring-offset-blue-950";

  return (
    <section ref={ref} data-paused={!onScreen} className="lch-root relative overflow-hidden bg-blue-950 px-4 py-16 text-white sm:px-6 sm:py-24">
      <style>{`
        @keyframes launch-countdown-hero-with-flip-clock-and-waitlist-dot-burst-top { from { transform: rotateX(0deg); filter: brightness(1); } to { transform: rotateX(-90deg); filter: brightness(0.6); } }
        @keyframes launch-countdown-hero-with-flip-clock-and-waitlist-dot-burst-bottom { 0% { transform: rotateX(90deg); filter: brightness(0.6); } 70% { transform: rotateX(-12deg); filter: brightness(1); } 100% { transform: rotateX(0deg); filter: brightness(1); } }
        @keyframes launch-countdown-hero-with-flip-clock-and-waitlist-dot-burst-blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0.25; } }
        @keyframes launch-countdown-hero-with-flip-clock-and-waitlist-dot-burst-halo { 0%, 100% { transform: scale(1); opacity: 0.55; } 50% { transform: scale(1.12); opacity: 0.85; } }
        .lch-flap-top { transform-origin: bottom; backface-visibility: hidden; animation: launch-countdown-hero-with-flip-clock-and-waitlist-dot-burst-top 0.26s ease-in both; }
        .lch-flap-bottom { transform-origin: top; backface-visibility: hidden; transform: rotateX(90deg); animation: launch-countdown-hero-with-flip-clock-and-waitlist-dot-burst-bottom 0.34s ease-out 0.26s both; }
        .lch-dot { transition: r 0.5s cubic-bezier(0.3, 1.6, 0.5, 1), opacity 0.6s ease, fill 0.6s ease; }
        .lch-blink { animation: launch-countdown-hero-with-flip-clock-and-waitlist-dot-burst-blink 1s steps(1) infinite; }
        .lch-halo { animation: launch-countdown-hero-with-flip-clock-and-waitlist-dot-burst-halo 6s ease-in-out infinite; }
        .lch-root[data-paused="true"] .lch-blink, .lch-root[data-paused="true"] .lch-halo { animation-play-state: paused; }
        @media (prefers-reduced-motion: reduce) { .lch-flap-top, .lch-flap-bottom { display: none; } .lch-blink, .lch-halo { animation: none; } .lch-dot { transition: none; } }
      `}</style>

      <div
        aria-hidden="true"
        className="absolute inset-0"
        style={{
          backgroundImage: "radial-gradient(rgba(191,219,254,0.28) 1.2px, transparent 1.2px)",
          backgroundSize: "26px 26px",
          maskImage: "radial-gradient(ellipse 75% 65% at 50% 45%, black 20%, transparent 75%)",
          WebkitMaskImage: "radial-gradient(ellipse 75% 65% at 50% 45%, black 20%, transparent 75%)",
        }}
      />
      <div aria-hidden="true" className="lch-halo absolute left-1/2 top-1/2 -ml-[340px] -mt-[220px] h-[440px] w-[680px] max-w-none rounded-full bg-blue-600/50 blur-3xl" />

      <div className="relative mx-auto max-w-4xl text-center">
        <motion.div initial={reduce ? false : { opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 80, damping: 16 }}>
          <p className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-3.5 py-1.5 text-sm font-medium text-blue-100">
            <span className="h-2 w-2 rounded-full bg-yellow-300" />
            Tessera 2 opens on 21 October
          </p>
          <h1 className="mx-auto mt-6 max-w-3xl text-4xl font-semibold leading-[1.05] tracking-tight sm:text-6xl">Your calendar is about to plan itself.</h1>
          <p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-blue-100">
            Tessera reads your week, guards your focus time and books everything else around it. Join the waitlist and get in on day one.
          </p>
        </motion.div>

        <div role="timer" aria-label="Time left until launch" className="relative mx-auto mt-16 flex w-fit items-start justify-center gap-2 sm:mt-20 sm:gap-4">
          <svg aria-hidden="true" viewBox="0 0 1000 300" className="pointer-events-none absolute left-1/2 top-12 hidden w-[124%] max-w-none -translate-x-1/2 -translate-y-1/2 overflow-visible sm:block lg:top-14">
            {track.map((p, i) => {
              const age = (((59 - (left % 60)) - i) % 60 + 60) % 60;
              const lit = age === 0;
              return (
                <circle
                  key={i}
                  cx={p.x}
                  cy={p.y}
                  r={lit ? 7 : 3.4}
                  fill={age < 10 ? "#fde047" : "#bfdbfe"}
                  opacity={lit ? 1 : age < 10 ? 0.85 - age * 0.07 : 0.22}
                  className="lch-dot"
                />
              );
            })}
          </svg>
          <span className="sr-only">
            {d} days, {h} hours and {m} minutes
          </span>
          {units.map((u, i) => (
            <div key={u.label} aria-hidden="true" className="flex items-start gap-2 sm:gap-4">
              {i > 0 && (
                <span className="lch-blink hidden h-24 flex-col items-center justify-center gap-3 sm:flex lg:h-28">
                  <span className="h-2 w-2 rounded-full bg-yellow-300" />
                  <span className="h-2 w-2 rounded-full bg-yellow-300" />
                </span>
              )}
              <motion.div
                initial={reduce ? false : { opacity: 0, y: -60, rotateX: -70 }}
                animate={{ opacity: 1, y: 0, rotateX: 0 }}
                transition={{ type: "spring", stiffness: 150, damping: 12, delay: 0.25 + i * 0.09 }}
              >
                <div className="flex gap-0.5 sm:gap-1.5">
                  <FlipDigit value={u.value[0]} reduce={reduce} />
                  <FlipDigit value={u.value[1]} reduce={reduce} />
                </div>
                <p className="mt-3 text-xs font-medium uppercase tracking-widest text-blue-200 sm:text-sm">{u.label}</p>
              </motion.div>
            </div>
          ))}
        </div>

        <motion.div
          className="relative mx-auto mt-16 max-w-lg sm:mt-20"
          initial={reduce ? false : { opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ type: "spring", stiffness: 80, damping: 16, delay: 0.7 }}
        >
          {/* The success burst: plain round dots and two rings, thrown from the middle of the form. */}
          {joined && !reduce && (
            <div key={bursts} aria-hidden="true" className="pointer-events-none absolute left-1/2 top-7 z-10">
              {[0, 1].map((r) => (
                <motion.span
                  key={r}
                  className="absolute -left-48 -top-48 h-96 w-96 rounded-full border-2 border-yellow-300"
                  initial={{ scale: 0.05, opacity: 0.9 }}
                  animate={{ scale: 1 + r * 0.4, opacity: 0 }}
                  transition={{ duration: 0.9 + r * 0.25, ease: [0.1, 0.7, 0.2, 1], delay: r * 0.08 }}
                />
              ))}
              {dots.map((dot, i) => (
                <motion.span
                  key={i}
                  className="absolute rounded-full"
                  style={{ width: dot.size, height: dot.size, left: -dot.size / 2, top: -dot.size / 2, backgroundColor: dot.colour }}
                  initial={{ x: 0, y: 0, scale: 0, opacity: 1 }}
                  animate={{ x: dot.x, y: [0, dot.y, dot.y + 46], scale: [0, 1.2, 0.2], opacity: [1, 1, 0] }}
                  transition={{ duration: dot.time, delay: dot.delay, ease: [0.12, 0.75, 0.25, 1], times: [0, 0.55, 1] }}
                />
              ))}
            </div>
          )}

          <AnimatePresence mode="wait" initial={false}>
            {joined ? (
              <motion.div
                key="done"
                role="status"
                initial={reduce ? false : { opacity: 0, scale: 0.85 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.95 }}
                transition={{ type: "spring", stiffness: 300, damping: 18 }}
                className="flex items-center gap-4 rounded-2xl border border-yellow-300/50 bg-white/10 p-3 pr-4 text-left"
              >
                <span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-yellow-300 text-blue-950">
                  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-6 w-6">
                    <motion.path d="M4.5 10.5l3.5 3.5 7.5-8" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.35, delay: 0.15 }} />
                  </svg>
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block font-semibold">You are number {(AHEAD + 1).toLocaleString("en-US")} in line.</span>
                  <span className="block truncate text-sm text-blue-100">We will write to {email.trim()} on launch morning.</span>
                </span>
                <button
                  type="button"
                  onClick={() => {
                    setJoined(false);
                    setEmail("");
                  }}
                  className={`inline-flex min-h-[40px] shrink-0 items-center rounded-lg px-3 text-sm font-medium text-yellow-200 underline-offset-4 hover:underline ${ring}`}
                >
                  Undo
                </button>
              </motion.div>
            ) : (
              <motion.form key="form" noValidate onSubmit={submit} initial={reduce ? false : { opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} transition={{ duration: 0.18 }}>
                <div className="flex flex-col gap-2 rounded-2xl border border-white/20 bg-white/10 p-2 sm:flex-row">
                  <label htmlFor="lch-email" className="sr-only">
                    Email address
                  </label>
                  <input
                    ref={input}
                    id="lch-email"
                    type="email"
                    autoComplete="email"
                    placeholder="[email protected]"
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                    aria-invalid={!!error}
                    aria-describedby={error ? "lch-error" : undefined}
                    className="min-h-[48px] w-full min-w-0 flex-1 rounded-xl bg-transparent px-4 text-base text-white placeholder:text-blue-200/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-300"
                  />
                  <button type="submit" className={`min-h-[48px] rounded-xl bg-yellow-300 px-6 text-sm font-semibold text-blue-950 transition hover:bg-yellow-200 active:scale-95 ${ring}`}>
                    Join the waitlist
                  </button>
                </div>
                {error && (
                  <p id="lch-error" role="alert" className="mt-2 text-left text-sm text-yellow-200">
                    {error}
                  </p>
                )}
              </motion.form>
            )}
          </AnimatePresence>

          <div className="mt-6 flex items-center justify-center gap-3 text-sm text-blue-100">
            <span aria-hidden="true" className="flex -space-x-2">
              {people.map((p) => (
                <span key={p.initials} className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold text-blue-950 ring-2 ring-blue-950 ${p.tone}`}>
                  {p.initials}
                </span>
              ))}
            </span>
            {AHEAD.toLocaleString("en-US")} people are already in line
          </div>
        </motion.div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/launch-countdown-hero-with-flip-clock-and-waitlist-dot-burst.json

When to use it

How to customize it

Accessibility

Questions

How does the flip clock animation work?

Each card is four half-height layers. Two static layers show the top of the new digit and the bottom of the old one. A third layer carries the top of the old digit and rotates down to 90 degrees around its bottom edge. Then a fourth, carrying the bottom of the new digit, rotates from 90 degrees to flat around its top edge. A `key` that changes with every tick restarts the two keyframes.

How is one digit split across two halves?

Each half clips its content with `overflow-hidden`, and inside it the digit sits in a box twice the height of the half. The top half pins that box to its top and the bottom half pins it to its bottom, so each one shows exactly half of the same centred character.

Does the countdown drift when the tab is in the background?

No. It does not subtract one each second. It stores the time the page opened and works out the remaining seconds from the current time on every tick, so it is correct again as soon as the tab returns.

How is the success burst built?

Forty small circles are placed at the centre of the form. Each has its own angle, distance, size, colour and duration, all worked out once from its index. On success they animate outward, drop slightly as if under gravity, shrink and fade. Two bordered rings scale up behind them.

Where do I send the email address?

The `submit` function only validates and shows the confirmation. Add your own request there, and call `setJoined(true)` after your server answers.

Related: Rolling Letter Departure Board Headline, Split Hero with Email Capture, Button That Bursts Particles on Press.

More hero sections or all free UI components. Start for free to have a full site built around it.

More in Hero Sections

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap