Retour au catalogue
Circular Progress
Anneaux de progression SVG circulaires pour jours, heures, minutes et secondes.
countdownmedium Both Responsive a11y
elegantminimaleventsaasuniversalcentered
Theme
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Bell } from "lucide-react";
interface CountdownCircularProgressProps {
title?: string;
subtitle?: string;
targetDate?: string;
ctaLabel?: string;
ctaUrl?: string;
}
const E: [number, number, number, number] = [0.16, 1, 0.3, 1];
const SIZE = 110;
const STROKE = 6;
const R = (SIZE - STROKE) / 2;
const CIRC = 2 * Math.PI * R;
function calc(target: string) {
const d = new Date(target).getTime() - Date.now();
if (d <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
return {
days: Math.floor(d / 864e5),
hours: Math.floor((d / 36e5) % 24),
minutes: Math.floor((d / 6e4) % 60),
seconds: Math.floor((d / 1e3) % 60),
};
}
const UNITS = [
{ key: "days", label: "Jours", max: 365 },
{ key: "hours", label: "Heures", max: 24 },
{ key: "minutes", label: "Minutes", max: 60 },
{ key: "seconds", label: "Secondes", max: 60 },
] as const;
function Ring({ value, max, label, delay }: { value: number; max: number; label: string; delay: number }) {
const pct = value / max;
const offset = CIRC * (1 - pct);
return (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay, ease: E }}
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "0.5rem" }}
>
<div style={{ position: "relative", width: SIZE, height: SIZE }}>
<svg width={SIZE} height={SIZE} style={{ transform: "rotate(-90deg)" }}>
<circle cx={SIZE / 2} cy={SIZE / 2} r={R} fill="none" stroke="var(--color-border)" strokeWidth={STROKE} />
<motion.circle
cx={SIZE / 2}
cy={SIZE / 2}
r={R}
fill="none"
stroke="var(--color-accent)"
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={CIRC}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 0.6, ease: E }}
/>
</svg>
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontFamily: "var(--font-sans)", fontSize: "clamp(1.5rem, 3vw, 2rem)", fontWeight: 800, color: "var(--color-foreground)", fontVariantNumeric: "tabular-nums" }}>
{String(value).padStart(2, "0")}
</span>
</div>
</div>
<span style={{ fontSize: "0.6875rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--color-foreground-muted)", fontFamily: "var(--font-sans)" }}>{label}</span>
</motion.div>
);
}
export default function CountdownCircularProgress({
title = "Bientot",
subtitle = "",
targetDate = "2026-12-31T00:00:00",
ctaLabel = "Me notifier",
ctaUrl = "#",
}: CountdownCircularProgressProps) {
const [time, setTime] = useState(calc(targetDate));
useEffect(() => {
const id = setInterval(() => setTime(calc(targetDate)), 1000);
return () => clearInterval(id);
}, [targetDate]);
return (
<section style={{ padding: "var(--section-padding-y) 0", 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.h2 initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.6, ease: E }} style={{ fontFamily: "var(--font-serif)", fontSize: "clamp(1.75rem, 3.5vw, 2.75rem)", fontWeight: 600, color: "var(--color-foreground)", marginBottom: "0.75rem" }}>
{title}
</motion.h2>
{subtitle && (
<motion.p initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ delay: 0.1 }} style={{ fontSize: "1rem", color: "var(--color-foreground-muted)", fontFamily: "var(--font-sans)", maxWidth: "480px", margin: "0 auto 3rem" }}>
{subtitle}
</motion.p>
)}
<div style={{ display: "flex", justifyContent: "center", gap: "clamp(1rem, 4vw, 2.5rem)", marginBottom: "3rem", flexWrap: "wrap" }}>
{UNITS.map((u, i) => (
<Ring key={u.key} value={time[u.key]} max={u.max} label={u.label} delay={i * 0.1} />
))}
</div>
<motion.div initial={{ opacity: 0, y: 8 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.45, delay: 0.3, ease: E }}>
<a href={ctaUrl} style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem", padding: "0.875rem 2.25rem", borderRadius: "var(--radius-full)", background: "var(--color-accent)", color: "var(--color-background)", fontWeight: 600, fontSize: "0.9375rem", textDecoration: "none", fontFamily: "var(--font-sans)" }}>
<Bell size={16} /> {ctaLabel}
</a>
</motion.div>
</div>
</section>
);
}