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

Avis

Circular Progress — React Countdown Section — Incubator