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