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

How to build scroll-triggered animated progress bars in React

To build scroll-triggered animated progress bars in React, use Framer Motion's useInView to detect when the section enters the viewport, then drive each bar's fill with a useSpring connected to a useMotionValue. The same spring drives a live percentage counter via useTransform.

  • Stack: React 18 + Framer Motion 11 + CSS custom properties, ~240 lines, zero extra dependencies.
  • Core API: useMotionValue, useSpring (damping 20, stiffness 60), useTransform, useInView.
  • Each bar animates in sequence with a 100ms stagger (index * 100ms delay) after the section enters view.
  • Accessible: values are readable text; the bar fill is a decorative layer with no ARIA required.
  • Responsive: the two-column split collapses gracefully on narrow screens via CSS grid.

Metrics Animated Bars is a split-layout React section that turns percentage data into something you watch happen. When the section scrolls into view, horizontal bars fill from left to right with a spring-physics animation while a live counter climbs to the final value. The layout puts a heading column on the left and the bar list on the right, giving each side room to breathe.

Anatomy

The section is a CSS grid with two equal columns. The left column holds an optional badge, an h2 title, and a subtitle paragraph, all fading in together on scroll. The right column holds the bar list: each item is a MetricBar subcomponent containing a label row (label text, optional description, and the live percentage value) above a 6px-tall track with an animated fill layer inside it.

How it works

A single useInView hook watches the right-column container with a -80px margin, triggering once. When inView becomes true, each MetricBar fires a setTimeout delayed by index * 100ms, then calls motionVal.set(metric.value). The motionVal feeds a useSpring (damping 20, stiffness 60) that eases into the target value with a gentle elastic overshoot. useTransform maps the spring output to scaleX between 0 and 1, which drives the fill div's transformOrigin:'left' scale. A separate useTransform converts the same spring to a percentage string for the counter, subscribed via displayVal.on('change') to keep it in sync with the bar.

How to build it in React

  1. Set up the motion value and spring

    Inside MetricBar, create a useMotionValue starting at 0 and pipe it through useSpring. The spring's damping and stiffness control how elastic the fill feels. Higher stiffness means a snappier bar; lower damping extends the bounce.

    const motionVal = useMotionValue(0);
    const spring = useSpring(motionVal, { damping: 20, stiffness: 60 });
  2. Drive the fill and the counter from the same spring

    Use two useTransform calls on the spring: one maps [0, 100] to [0, 1] for scaleX on the fill div, and another formats the value as a percentage string for the counter. Subscribe to the string transform with .on('change') to keep a React state in sync.

    const scaleX = useTransform(spring, [0, 100], [0, 1]);
    const displayVal = useTransform(spring, (v) => `${Math.round(v)}%`);
    
    useEffect(() => {
      const unsub = displayVal.on("change", (v) => setDisplayText(v));
      return unsub;
    }, [displayVal]);
  3. Trigger on scroll with a staggered delay

    Place a ref on the container div and pass it to useInView with once:true and a -80px margin so the animation fires before the section fully enters the viewport. When inView becomes true, use a setTimeout with index * 100ms before setting the motion value, so each bar fills in sequence rather than all at once.

    const containerRef = useRef<HTMLDivElement>(null);
    const inView = useInView(containerRef, { once: true, margin: "-80px" });
    
    useEffect(() => {
      if (inView) {
        const timer = setTimeout(() => {
          motionVal.set(metric.value);
        }, index * 100);
        return () => clearTimeout(timer);
      }
    }, [inView, metric.value, motionVal, index]);
  4. Apply scaleX to the fill with transformOrigin left

    Render a motion.div inside the track with position:absolute and inset:0, set transformOrigin to 'left', and bind the scaleX motion value directly to the style prop. The bar then grows from left to right as the spring value rises.

    <motion.div
      style={{
        position: "absolute",
        inset: 0,
        backgroundColor: "var(--color-accent)",
        transformOrigin: "left",
        scaleX,
      }}
    />

When to use it

Reach for this component on About, Services, or Case Study pages where you want to show expertise or performance data in a scannable, visual way. It works best with 4 to 8 metrics that genuinely have percentage representations, like skill levels, customer satisfaction scores, or goal completion rates. Avoid it when the data is not naturally a percentage, when the metrics need precise decimal values, or when users need to compare bars across multiple datasets at once, a real chart library handles those cases better.

Used by

  • Stripe, Uses animated progress-style indicators in its dashboard onboarding to show setup completion rates.
  • Webflow, Employs horizontal animated bars on its pricing and feature comparison pages to highlight plan capacities.
  • Figma, Shows animated fill bars in its community and plugin usage stats sections.

FAQ

Why useSpring instead of a plain animate() call?

useSpring gives the fill physical inertia: the bar overshoots slightly and settles, which reads as natural movement rather than a mechanical tween. You can tune damping and stiffness to match the brand's energy without touching any duration or easing curve.

Can I display values other than percentages?

The component expects values in the 0 to 100 range for the bar fill. If your data uses a different scale, normalize it to that range before passing it in, and adjust the displayVal useTransform to format the label as you need (currency, score, etc.).

How do I prevent the animation from replaying on re-render?

The useInView hook is called with once:true, so it fires a single time when the container first enters the viewport. Subsequent re-renders of the parent do not reset it. If you need to replay, unmount and remount the component.

Does the stagger break if many bars are rendered?

Each bar adds 100ms of delay, so 8 bars means the last one starts after 700ms. That stays perceptible and pleasant. Beyond 10 bars the cumulative delay becomes noticeable; consider halving the stagger to 50ms or capping the delay at a maximum value.

"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;

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Animated Progress Bars with Framer Motion, Tutorial