A labelled progress bar whose fill springs to each new value with a glowing leading edge, a sheen sweeping along it, and a percentage that counts with it.
Preview
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add progress-barWrites components/ui/progress-bar.tsx and components/styles/progress-bar.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ProgressBar } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ProgressBar />6 );7}Props
Accessibility
Uses role="progressbar" with aria-valuemin/max/now and an aria-valuetext percentage, labelled by its visible label. The animated readout is aria-hidden so values aren't announced mid-animation. Reduced motion jumps straight to the value and removes the sheen.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";5import styles from "../styles/progress-bar.module.css";6 7export interface ProgressBarProps {8 /** Current value */9 value?: number;10 /** Maximum value */11 max?: number;12 /** Label shown above the bar (empty hides the header) */13 label?: string;14 /** Show the percentage readout */15 showValue?: boolean;16 /** Fill color */17 color?: string;18 /** Track color */19 trackColor?: string;20 /** Bar thickness in pixels */21 height?: number;22 /** Moving sheen across the fill */23 sheen?: boolean;24 /** Spring stiffness — lower is bouncier and slower */25 stiffness?: number;26 /** Demo mode: keep moving between sample values to show the spring */27 autoPlay?: boolean;28 /** Width in pixels */29 width?: number;30 /** Optional className merged onto the root */31 className?: string;32 /** Optional inline styles merged onto the root */33 style?: React.CSSProperties;34}35 36const DEMO_VALUES = [65, 28, 92, 46];37 38export function ProgressBar({39 value = 65,40 max = 100,41 label = "Progress",42 showValue = true,43 color = "#ff8a1f",44 trackColor,45 height = 10,46 sheen = true,47 stiffness = 120,48 autoPlay = false,49 width = 360,50 className = "",51 style,52}: ProgressBarProps) {53 const reduceMotion = useReducedMotion();54 const labelId = React.useId();55 const [demoIndex, setDemoIndex] = React.useState(-1);56 const shown = autoPlay && demoIndex >= 0 ? (DEMO_VALUES[demoIndex] / 100) * max : value;57 const pct = max > 0 ? Math.min(100, Math.max(0, (shown / max) * 100)) : 0;58 59 const raw = useMotionValue(0);60 const spring = useSpring(raw, { stiffness, damping: Math.max(8, stiffness / 9), mass: 0.9 });61 const widthPct = useTransform(spring, (v) => `${Math.min(100, Math.max(0, v))}%`);62 const readout = React.useRef<HTMLSpanElement>(null);63 64 React.useEffect(() => {65 if (reduceMotion) {66 raw.jump(pct);67 spring.jump(pct);68 } else raw.set(pct);69 // Count the readout along with the spring.70 const controls = animate(Number(readout.current?.dataset.value ?? 0), pct, {71 duration: reduceMotion ? 0 : 0.8,72 ease: [0.22, 1, 0.36, 1],73 onUpdate: (v) => {74 if (readout.current) {75 readout.current.textContent = `${Math.round(v)}%`;76 readout.current.dataset.value = String(v);77 }78 },79 });80 return () => controls.stop();81 }, [pct, reduceMotion, raw, spring]);82 83 React.useEffect(() => {84 if (!autoPlay || reduceMotion) return;85 setDemoIndex(0);86 const id = window.setInterval(() => setDemoIndex((i) => (i + 1) % DEMO_VALUES.length), 1800);87 return () => window.clearInterval(id);88 }, [autoPlay, reduceMotion]);89 90 return (91 <div92 className={`${styles.root} ${className}`.trim()}93 style={94 {95 "--mf-pb-color": color,96 "--mf-pb-height": `${height}px`,97 "--mf-pb-width": `${width}px`,98 ...(trackColor ? { "--mf-pb-track": trackColor } : null),99 ...style,100 } as React.CSSProperties101 }102 >103 {(label || showValue) && (104 <div className={styles.header}>105 <span id={labelId}>{label}</span>106 {showValue && (107 <span ref={readout} className={styles.value} aria-hidden="true" data-value="0">108 0%109 </span>110 )}111 </div>112 )}113 <div114 role="progressbar"115 aria-labelledby={label ? labelId : undefined}116 aria-label={label ? undefined : "Progress"}117 aria-valuemin={0}118 aria-valuemax={max}119 aria-valuenow={Math.round(shown)}120 aria-valuetext={`${Math.round(pct)}%`}121 className={styles.track}122 >123 <motion.div className={styles.fill} style={{ width: widthPct }}>124 {sheen && !reduceMotion && <span className={styles.sheen} aria-hidden="true" />}125 </motion.div>126 </div>127 </div>128 );129}