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