Retour au catalogue
Metrics Animated Bars
Barres de progression horizontales qui se remplissent au scroll avec un spring physique (élastique). Chaque barre : label, description, pourcentage animé synchronisé. Layout split gauche (titre) / droite (barres).
metricsmedium Both Responsive a11y
minimalcorporateelegantsaasagencyportfoliosplit
Theme
"use client";
import { useEffect, useRef, useState } from "react";
import {
motion,
useInView,
useSpring,
useTransform,
useMotionValue,
} from "framer-motion";
interface MetricItem {
id: string;
label: string;
value: number;
description?: string;
}
interface MetricsAnimatedBarsProps {
badge?: string;
title?: string;
subtitle?: string;
metrics: MetricItem[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
function MetricBar({
metric,
index,
inView,
}: {
metric: MetricItem;
index: number;
inView: boolean;
}) {
const motionVal = useMotionValue(0);
const spring = useSpring(motionVal, { damping: 20, stiffness: 60 });
const displayVal = useTransform(spring, (v) => `${Math.round(v)}%`);
const [displayText, setDisplayText] = useState("0%");
useEffect(() => {
const unsubscribe = displayVal.on("change", (v) => setDisplayText(v));
return unsubscribe;
}, [displayVal]);
useEffect(() => {
if (inView) {
const timer = setTimeout(() => {
motionVal.set(metric.value);
}, index * 100);
return () => clearTimeout(timer);
}
}, [inView, metric.value, motionVal, index]);
const scaleX = useTransform(spring, [0, 100], [0, 1]);
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={inView ? { opacity: 1, x: 0 } : { opacity: 0, x: -20 }}
transition={{ duration: 0.5, delay: index * 0.1, ease: EASE }}
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
>
{/* Label row */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
gap: "1rem",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "0.1rem" }}>
<span
style={{
fontSize: "0.9375rem",
fontWeight: 600,
color: "var(--color-foreground)",
lineHeight: 1.3,
}}
>
{metric.label}
</span>
{metric.description && (
<span
style={{
fontSize: "0.75rem",
color: "var(--color-foreground-muted)",
lineHeight: 1.4,
}}
>
{metric.description}
</span>
)}
</div>
<span
style={{
fontSize: "0.9375rem",
fontWeight: 700,
color: "var(--color-accent)",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
minWidth: "3rem",
textAlign: "right",
}}
>
{displayText}
</span>
</div>
{/* Bar track */}
<div
style={{
position: "relative",
height: "6px",
backgroundColor: "var(--color-border)",
borderRadius: "var(--radius-full, 9999px)",
overflow: "hidden",
}}
>
<motion.div
style={{
position: "absolute",
inset: 0,
backgroundColor: "var(--color-accent)",
borderRadius: "var(--radius-full, 9999px)",
transformOrigin: "left",
scaleX,
}}
/>
</div>
</motion.div>
);
}
export default function MetricsAnimatedBars({
badge,
title,
subtitle,
metrics,
}: MetricsAnimatedBarsProps) {
const containerRef = useRef<HTMLDivElement>(null);
const inView = useInView(containerRef, { once: true, margin: "-80px" });
return (
<section
style={{
paddingTop: "var(--section-padding-y, 5rem)",
paddingBottom: "var(--section-padding-y, 5rem)",
backgroundColor: "var(--color-background)",
}}
>
<div
style={{
maxWidth: "var(--container-max-width, 72rem)",
margin: "0 auto",
padding: "0 var(--container-padding-x, 1.5rem)",
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "4rem",
alignItems: "start",
}}
>
{/* Left column — header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.55, ease: EASE }}
>
{badge && (
<span
style={{
display: "inline-block",
fontSize: "0.6875rem",
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
padding: "0.25rem 0.875rem",
borderRadius: "var(--radius-full, 9999px)",
border: "1px solid var(--color-border)",
color: "var(--color-accent)",
marginBottom: "1.25rem",
}}
>
{badge}
</span>
)}
{title && (
<h2
style={{
fontSize: "clamp(1.875rem, 3vw, 2.75rem)",
fontWeight: 700,
letterSpacing: "-0.03em",
color: "var(--color-foreground)",
lineHeight: 1.15,
marginBottom: "1rem",
}}
>
{title}
</h2>
)}
{subtitle && (
<p
style={{
fontSize: "1rem",
color: "var(--color-foreground-muted)",
lineHeight: 1.65,
maxWidth: "28rem",
}}
>
{subtitle}
</p>
)}
</motion.div>
{/* Right column — bars */}
<div
ref={containerRef}
style={{ display: "flex", flexDirection: "column", gap: "1.75rem" }}
>
{metrics.map((metric, i) => (
<MetricBar
key={metric.id}
metric={metric}
index={i}
inView={inView}
/>
))}
</div>
</div>
</div>
</section>
);
}