Retour au catalogue

Countdown Flip Clock

Compte a rebours avec animation flip card mecanique. Chaque chiffre se retourne en rotateX comme une horloge d'aeroport : le panneau du haut part en -90deg pendant que le nouveau arrive de +90deg. Separateur deux-points qui pulse.

countdowncomplex Both Responsive a11y
boldelegantminimalsaaseventuniversalcentered
Theme
"use client";

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

interface CountdownFlipClockProps {
  title?: string;
  subtitle?: string;
  targetDate?: string;
}

const EASE: [number, number, number, number] = [0.16, 1, 0.3, 1];

const UNITS = [
  { key: "days" as const, label: "Jours" },
  { key: "hours" as const, label: "Heures" },
  { key: "minutes" as const, label: "Min" },
  { key: "seconds" as const, label: "Sec" },
];

type TimeState = Record<"days" | "hours" | "minutes" | "seconds", number>;

function calcTime(target: string): TimeState {
  const diff = new Date(target).getTime() - Date.now();
  if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
  return {
    days: Math.floor(diff / 864e5),
    hours: Math.floor((diff / 36e5) % 24),
    minutes: Math.floor((diff / 6e4) % 60),
    seconds: Math.floor((diff / 1e3) % 60),
  };
}

function FlipHalf({
  children,
  position,
}: {
  children: React.ReactNode;
  position: "top" | "bottom";
}) {
  const isTop = position === "top";
  return (
    <div
      style={{
        position: "absolute",
        left: 0,
        right: 0,
        height: "50%",
        top: isTop ? 0 : "50%",
        overflow: "hidden",
        display: "flex",
        alignItems: isTop ? "flex-end" : "flex-start",
        justifyContent: "center",
        background: isTop
          ? "var(--color-background-card)"
          : "color-mix(in srgb, var(--color-background-card) 85%, var(--color-background) 15%)",
      }}
    >
      <span
        style={{
          display: "block",
          fontFamily: "var(--font-sans)",
          fontSize: "clamp(2rem, 5vw, 3.75rem)",
          fontWeight: 800,
          lineHeight: 1,
          color: "var(--color-foreground)",
          transform: isTop ? "translateY(50%)" : "translateY(-50%)",
          userSelect: "none",
        }}
      >
        {children}
      </span>
    </div>
  );
}

function FlipCard({ digit }: { digit: string }) {
  const prevDigit = useRef(digit);
  const [flipping, setFlipping] = useState(false);
  const [displayDigit, setDisplayDigit] = useState(digit);
  const [nextDigit, setNextDigit] = useState(digit);

  useEffect(() => {
    if (prevDigit.current !== digit) {
      setNextDigit(digit);
      setFlipping(true);
      const t = setTimeout(() => {
        setDisplayDigit(digit);
        setFlipping(false);
        prevDigit.current = digit;
      }, 350);
      return () => clearTimeout(t);
    }
  }, [digit]);

  const cardStyle: React.CSSProperties = {
    width: "clamp(2.75rem, 6.5vw, 4.5rem)",
    height: "clamp(4rem, 9vw, 6.5rem)",
    position: "relative",
    borderRadius: "var(--radius-md)",
    border: "1px solid var(--color-border)",
    overflow: "hidden",
    boxShadow:
      "0 2px 8px rgba(0,0,0,0.08), 0 8px 32px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.06)",
  };

  return (
    <div style={{ ...cardStyle, perspective: "600px" }}>
      {/* Static bottom half — incoming digit */}
      <FlipHalf position="bottom">{nextDigit}</FlipHalf>

      {/* Static top half — current digit */}
      <FlipHalf position="top">{displayDigit}</FlipHalf>

      {/* Divider line */}
      <div
        aria-hidden
        style={{
          position: "absolute",
          top: "50%",
          left: 0,
          right: 0,
          height: "1px",
          background: "var(--color-border)",
          zIndex: 10,
          transform: "translateY(-0.5px)",
        }}
      />

      {/* Flipping top half — old digit folds down */}
      <AnimatePresence>
        {flipping && (
          <motion.div
            key={`top-${displayDigit}-${nextDigit}`}
            initial={{ rotateX: 0 }}
            animate={{ rotateX: -90 }}
            exit={{}}
            transition={{ duration: 0.18, ease: [0.4, 0, 0.6, 1] }}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              right: 0,
              height: "50%",
              overflow: "hidden",
              transformOrigin: "bottom center",
              zIndex: 6,
              background: "var(--color-background-card)",
              display: "flex",
              alignItems: "flex-end",
              justifyContent: "center",
              borderRadius: "var(--radius-md) var(--radius-md) 0 0",
              backfaceVisibility: "hidden",
            }}
          >
            <span
              style={{
                fontFamily: "var(--font-sans)",
                fontSize: "clamp(2rem, 5vw, 3.75rem)",
                fontWeight: 800,
                lineHeight: 1,
                color: "var(--color-foreground)",
                transform: "translateY(50%)",
                userSelect: "none",
              }}
            >
              {displayDigit}
            </span>
          </motion.div>
        )}
      </AnimatePresence>

      {/* Flipping bottom half — new digit reveals */}
      <AnimatePresence>
        {flipping && (
          <motion.div
            key={`bot-${nextDigit}-${displayDigit}`}
            initial={{ rotateX: 90 }}
            animate={{ rotateX: 0 }}
            exit={{}}
            transition={{ duration: 0.18, ease: [0.4, 0, 0.6, 1], delay: 0.18 }}
            style={{
              position: "absolute",
              top: "50%",
              left: 0,
              right: 0,
              height: "50%",
              overflow: "hidden",
              transformOrigin: "top center",
              zIndex: 6,
              background:
                "color-mix(in srgb, var(--color-background-card) 85%, var(--color-background) 15%)",
              display: "flex",
              alignItems: "flex-start",
              justifyContent: "center",
              borderRadius: "0 0 var(--radius-md) var(--radius-md)",
              backfaceVisibility: "hidden",
            }}
          >
            <span
              style={{
                fontFamily: "var(--font-sans)",
                fontSize: "clamp(2rem, 5vw, 3.75rem)",
                fontWeight: 800,
                lineHeight: 1,
                color: "var(--color-foreground)",
                transform: "translateY(-50%)",
                userSelect: "none",
              }}
            >
              {nextDigit}
            </span>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function FlipUnit({ value, label }: { value: number; label: string }) {
  const str = String(value).padStart(2, "0");
  return (
    <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.625rem" }}>
      <div style={{ display: "flex", gap: "0.25rem" }}>
        <FlipCard digit={str[0]} />
        <FlipCard digit={str[1]} />
      </div>
      <span
        style={{
          fontSize: "0.6875rem",
          fontWeight: 600,
          textTransform: "uppercase",
          letterSpacing: "0.1em",
          color: "var(--color-foreground-muted)",
          fontFamily: "var(--font-sans)",
        }}
      >
        {label}
      </span>
    </div>
  );
}

function Separator() {
  return (
    <motion.div
      animate={{ opacity: [1, 0.25, 1] }}
      transition={{ duration: 1, repeat: Infinity, ease: "easeInOut" }}
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "0.375rem",
        alignSelf: "flex-start",
        paddingTop: "clamp(1.125rem, 2.75vw, 1.75rem)",
      }}
      aria-hidden
    >
      <div
        style={{
          width: 5,
          height: 5,
          borderRadius: "50%",
          background: "var(--color-accent)",
          opacity: 0.7,
        }}
      />
      <div
        style={{
          width: 5,
          height: 5,
          borderRadius: "50%",
          background: "var(--color-accent)",
          opacity: 0.7,
        }}
      />
    </motion.div>
  );
}

export default function CountdownFlipClock({
  title = "Quelque chose arrive",
  subtitle = "Preparez-vous pour quelque chose d'exceptionnel. La date approche.",
  targetDate = "2027-01-01T00:00:00",
}: CountdownFlipClockProps) {
  const [time, setTime] = useState<TimeState>(() => calcTime(targetDate));

  useEffect(() => {
    const id = setInterval(() => setTime(calcTime(targetDate)), 1000);
    return () => clearInterval(id);
  }, [targetDate]);

  return (
    <section
      style={{
        paddingTop: "var(--section-padding-y)",
        paddingBottom: "var(--section-padding-y)",
        background: "var(--color-background)",
        minHeight: "60vh",
        display: "flex",
        alignItems: "center",
      }}
    >
      <div
        style={{
          width: "100%",
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
          textAlign: "center",
        }}
      >
        <motion.p
          initial={{ opacity: 0, y: 10 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.45, ease: EASE }}
          style={{
            fontSize: "0.8125rem",
            fontWeight: 600,
            textTransform: "uppercase",
            letterSpacing: "0.12em",
            color: "var(--color-accent)",
            fontFamily: "var(--font-sans)",
            marginBottom: "1rem",
          }}
        >
          Compte a rebours
        </motion.p>

        <motion.h2
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.55, delay: 0.05, ease: EASE }}
          style={{
            fontFamily: "var(--font-sans)",
            fontSize: "clamp(1.875rem, 4vw, 3rem)",
            fontWeight: 700,
            lineHeight: 1.1,
            letterSpacing: "-0.02em",
            color: "var(--color-foreground)",
            marginBottom: "1rem",
          }}
        >
          {title}
        </motion.h2>

        <motion.p
          initial={{ opacity: 0, y: 12 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.5, delay: 0.1, ease: EASE }}
          style={{
            fontSize: "1rem",
            lineHeight: 1.7,
            color: "var(--color-foreground-muted)",
            maxWidth: "460px",
            margin: "0 auto 3.5rem",
          }}
        >
          {subtitle}
        </motion.p>

        <motion.div
          initial={{ opacity: 0, scale: 0.96 }}
          whileInView={{ opacity: 1, scale: 1 }}
          viewport={{ once: true }}
          transition={{ duration: 0.55, delay: 0.15, ease: EASE }}
          style={{
            display: "inline-flex",
            alignItems: "flex-start",
            gap: "clamp(0.5rem, 2vw, 1.25rem)",
          }}
        >
          {UNITS.map((unit, i) => (
            <div key={unit.key} style={{ display: "contents" }}>
              <FlipUnit value={time[unit.key]} label={unit.label} />
              {i < UNITS.length - 1 && <Separator />}
            </div>
          ))}
        </motion.div>
      </div>
    </section>
  );
}

Avis

Countdown Flip Clock — React Countdown Section — Incubator