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

Avis

Metrics Animated Bars — React Metrics Section — Incubator