Retour au catalogue
Stats Animated
Compteurs qui s'incrementent au scroll avec typographie serif elegante, barres accent et animation countup.
statsmedium Both Responsive a11y
eleganteditorialagencyportfoliogrid
Theme
"use client";
import { useRef, useEffect, useState } from "react";
import { motion, useInView } from "framer-motion";
interface StatItem {
value: number;
label: string;
suffix?: string;
prefix?: string;
}
interface StatsAnimatedProps {
title?: string;
stats?: StatItem[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
function CountUp({ target, prefix, suffix, inView }: { target: number; prefix?: string; suffix?: string; inView: boolean }) {
const [count, setCount] = useState(0);
useEffect(() => {
if (!inView) return;
let start = 0;
const duration = 2000;
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(eased * target);
setCount(current);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}, [inView, target]);
return (
<span>
{prefix || ""}{count}{suffix || ""}
</span>
);
}
export default function StatsAnimated({
title = "",
stats = [],
}: StatsAnimatedProps) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "-100px" });
return (
<section
style={{
padding: "var(--section-padding-y-lg) 0",
background: "var(--color-background)",
}}
>
<div
ref={ref}
style={{
maxWidth: "var(--container-max-width)",
margin: "0 auto",
padding: "0 var(--container-padding-x)",
textAlign: "center",
}}
>
{title && (
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={inView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, ease: EASE }}
style={{
fontSize: "clamp(1.5rem, 2.5vw, 2.25rem)",
fontWeight: 700,
letterSpacing: "-0.02em",
color: "var(--color-foreground)",
marginBottom: "4rem",
}}
>
{title}
</motion.h2>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr",
gap: "3rem 2rem",
}}
className="sm:!grid-cols-2 lg:!grid-cols-4"
>
{stats.map((stat, i) => (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 24 }}
animate={inView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: i * 0.12, ease: EASE }}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
}}
>
<span
style={{
fontFamily: "var(--font-serif)",
fontSize: "clamp(3rem, 5vw, 4.5rem)",
fontWeight: 400,
lineHeight: 1,
color: "var(--color-foreground)",
fontStyle: "italic",
}}
>
<CountUp
target={stat.value}
prefix={stat.prefix}
suffix={stat.suffix}
inView={inView}
/>
</span>
<div
style={{
width: "24px",
height: "2px",
background: "var(--color-accent)",
borderRadius: "1px",
}}
/>
<span
style={{
fontSize: "0.875rem",
fontWeight: 500,
color: "var(--color-foreground-muted)",
}}
>
{stat.label}
</span>
</motion.div>
))}
</div>
</div>
</section>
);
}