react-countup and react-spring are both animation libraries for React, but they solve different problems. react-countup is a specialized wrapper designed specifically for animating numeric values from a start point to an end point, often used for statistics or counters. react-spring is a general-purpose physics-based animation library that handles complex motion, transitions, and interactions across any CSS property or value. While react-countup focuses on ease of use for numbers, react-spring provides deep control over the animation lifecycle and physical behavior.
Both react-countup and react-spring bring motion to React applications, but they target different layers of the UI experience. react-countup is a specialist tool for counting numbers, while react-spring is a general engine for physical motion. Understanding their core differences helps you avoid over-engineering simple tasks or under-engineering complex interactions.
react-countup is built specifically to animate numbers from a start value to an end value.
countUp.js library to make it React-friendly.// react-countup: Dedicated number animation
import CountUp from 'react-countup';
function StatCard() {
return <CountUp end={1000} duration={2.5} separator="," />;
}
react-spring is a physics-based animation library for any animatable value.
// react-spring: General physics animation
import { useSpring, animated } from '@react-spring/web';
function StatCard() {
const props = useSpring({ to: { val: 1000 }, from: { val: 0 } });
return <animated.span>{props.val.to(n => n.toFixed(0))}</animated.span>;
}
react-countup offers both a component and a hook for flexibility.
// react-countup: Component vs Hook
import CountUp, { useCountUp } from 'react-countup';
// Component approach
<CountUp end={500} />
// Hook approach
function ManualCounter() {
const { countUpRef, start } = useCountUp({ end: 500 });
return <button onClick={start}><span ref={countUpRef} /></button>;
}
react-spring relies heavily on hooks to manage animation state.
// react-spring: Hook-based configuration
import { useSpring } from '@react-spring/web';
function InteractiveCounter() {
const [toggle, setToggle] = React.useState(false);
const props = useSpring({
to: { val: toggle ? 100 : 0 },
from: { val: 0 }
});
return <button onClick={() => setToggle(!toggle)}>Toggle</button>;
}
react-countup has built-in support for formatting numbers.
// react-countup: Built-in formatting
<CountUp
end={1234.56}
prefix="$"
suffix=" USD"
decimals={2}
separator=","
/>
// Output: $1,234.56 USD
react-spring requires manual formatting for text content.
// react-spring: Manual formatting
import { useSpring, animated } from '@react-spring/web';
const props = useSpring({ to: { val: 1234.56 }, from: { val: 0 } });
return (
<animated.span>
${props.val.to(n => n.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,'))} USD
</animated.span>
);
react-countup uses standard easing functions over a fixed duration.
duration in seconds and an easing function string.// react-countup: Fixed duration easing
<CountUp
end={100}
duration={3}
easingFn={(t, b, c, d) => t / d * c + b}
/>
react-spring uses mass, tension, and friction to define motion.
// react-spring: Physics configuration
const props = useSpring({
to: { val: 100 },
from: { val: 0 },
config: { mass: 1, tension: 170, friction: 26 }
});
react-countup is isolated to the text content it renders.
// react-countup: Text content only
<div className="card">
<CountUp end={100} /> {/* Only number animates */}
</div>
react-spring can animate the number and the layout simultaneously.
// react-spring: Unified motion
const props = useSpring({
to: { val: 100, opacity: 1, scale: 1 },
from: { val: 0, opacity: 0, scale: 0.5 }
});
return (
<animated.div style={{ opacity: props.opacity, scale: props.scale }}>
{props.val.to(n => n.toFixed(0))}
</animated.div>
);
| Feature | react-countup | react-spring |
|---|---|---|
| Primary Use | π’ Number counters | π¨ General motion & physics |
| Setup | β Props-based (Component or Hook) | πͺ Hook-based configuration |
| Formatting | π οΈ Built-in (prefix, suffix, etc.) | βοΈ Manual interpolation required |
| Motion Model | β±οΈ Fixed duration & easing | π Physics (tension, friction, mass) |
| Layout Animation | β Text content only | β Any CSS property + values |
| Interruption | β οΈ Restarts or resets | β Smooth interruption mid-flight |
react-countup is like a digital odometer πβit does one job perfectly and requires very little setup. Use it for dashboards, pricing tables, or any place where you just need a number to tick up reliably.
react-spring is like a physics lab π§ͺβit gives you the tools to build complex, interactive motion systems. Use it when the number animation is part of a larger interactive experience, like a game, a gesture-driven interface, or a highly polished marketing site.
Final Thought: If you only need to count up, react-countup saves time. If you need the count to feel alive and connected to other movements, react-spring is worth the extra code.
Choose react-countup if your primary goal is animating numeric values like statistics, prices, or scores. It handles formatting, easing, and decimal logic out of the box without extra configuration. This package is ideal when you need a quick, reliable counter without building custom animation logic. It reduces boilerplate significantly for standard number transitions.
Choose react-spring if you need complex motion systems involving physics, gestures, or layout transitions. It allows you to animate numbers alongside other UI elements using a unified physics engine. This library is best for interactive interfaces where motion needs to feel natural and respond to user input. It offers greater flexibility but requires more setup for simple number counters.
A configurable React component wrapper around CountUp.js.
Click here to view on CodeSandbox.

className: stringdecimal: stringdecimals: numberdelay: ?numberduration: numberend: numberprefix: stringredraw: booleanpreserveValue: booleanseparator: stringstart: numberplugin: CountUpPluginstartOnMount: booleansuffix: stringuseEasing: booleanuseGrouping: booleanuseIndianSeparators: booleaneasingFn: (t: number, b: number, c: number, d: number) => numberformattingFn: (value: number) => stringenableScrollSpy: booleanscrollSpyDelay: numberscrollSpyOnce: booleanonEnd: ({ pauseResume, reset, start, update }) => voidonStart: ({ pauseResume, reset, update }) => voidonPauseResume: ({ reset, start, update }) => voidonReset: ({ pauseResume, start, update }) => voidonUpdate: ({ pauseResume, reset, start }) => voidyarn add react-countup
import CountUp from 'react-countup';
<CountUp end={100} />
This will start a count up transition from 0 to 100 on render.
<CountUp
start={-875.039}
end={160527.012}
duration={2.75}
separator=" "
decimals={4}
decimal=","
prefix="EUR "
suffix=" left"
onEnd={() => console.log('Ended! π')}
onStart={() => console.log('Started! π¨')}
>
{({ countUpRef, start }) => (
<div>
<span ref={countUpRef} />
<button onClick={start}>Start</button>
</div>
)}
</CountUp>
The transition won't start on initial render as it needs to be triggered manually here.
Tip: If you need to start the render prop component immediately, you can set delay={0}.
<CountUp start={0} end={100}>
{({ countUpRef, start }) => (
<div>
<span ref={countUpRef} />
<button onClick={start}>Start</button>
</div>
)}
</CountUp>
Render start value but start transition on first render:
<CountUp start={0} end={100} delay={0}>
{({ countUpRef }) => (
<div>
<span ref={countUpRef} />
</div>
)}
</CountUp>
Note that delay={0} will automatically start the count up.
<CountUp delay={2} end={100} />
import { useCountUp } from 'react-countup';
const SimpleHook = () => {
useCountUp({ ref: 'counter', end: 1234567 });
return <span id="counter" />;
};
import { useCountUp } from 'react-countup';
const CompleteHook = () => {
const countUpRef = React.useRef(null);
const { start, pauseResume, reset, update } = useCountUp({
ref: countUpRef,
start: 0,
end: 1234567,
delay: 1000,
duration: 5,
onReset: () => console.log('Resetted!'),
onUpdate: () => console.log('Updated!'),
onPauseResume: () => console.log('Paused or resumed!'),
onStart: ({ pauseResume }) => console.log(pauseResume),
onEnd: ({ pauseResume }) => console.log(pauseResume),
});
return (
<div>
<div ref={countUpRef} />
<button onClick={start}>Start</button>
<button onClick={reset}>Reset</button>
<button onClick={pauseResume}>Pause/Resume</button>
<button onClick={() => update(2000)}>Update to 2000</button>
</div>
);
};
className: stringCSS class name of the span element.
Note: This won't be applied when using CountUp with render props.
decimal: stringSpecifies decimal character.
Default: .
decimals: numberAmount of decimals to display.
Default: 0
delay: numberDelay in seconds before starting the transition.
Default: null
Note:
delay={0}will automatically start the count up.
duration: numberDuration in seconds.
Default: 2
end: numberTarget value.
prefix: stringStatic text before the transitioning value.
redraw: booleanForces count up transition on every component update.
Default: false
preserveValue: booleanSave previously ended number to start every new animation from it.
Default: false
separator: stringSpecifies character of thousands separator.
start: numberInitial value.
Default: 0
plugin: CountUpPluginDefine plugin for alternate animations
startOnMount: booleanUse for start counter on mount for hook usage.
Default: true
suffix: stringStatic text after the transitioning value.
useEasing: booleanEnables easing. Set to false for a linear transition.
Default: true
useGrouping: booleanEnables grouping with separator.
Default: true
useIndianSeparators: booleanEnables grouping using indian separation, f.e. 1,00,000 vs 100,000
Default: false
easingFn: (t: number, b: number, c: number, d: number) => numberEasing function. Click here for more details.
Default: easeInExpo
formattingFn: (value: number) => stringFunction to customize the formatting of the number.
To prevent component from unnecessary updates this function should be memoized with useCallback
enableScrollSpy: booleanEnables start animation when target is in view.
scrollSpyDelay: numberDelay (ms) after target comes into view
scrollSpyOnce: booleanRun scroll spy only once
onEnd: ({ pauseResume, reset, start, update }) => voidCallback function on transition end.
onStart: ({ pauseResume, reset, update }) => voidCallback function on transition start.
onPauseResume: ({ reset, start, update }) => voidCallback function on pause or resume.
onReset: ({ pauseResume, start, update }) => voidCallback function on reset.
onUpdate: ({ pauseResume, reset, start }) => voidCallback function on update.
countUpRef: () => voidRef to hook the countUp instance to
pauseResume: () => voidPauses or resumes the transition
reset: () => voidResets to initial value
start: () => voidStarts or restarts the transition
update: (newEnd: number?) => voidUpdates transition to the new end value (if given)
By default, the animation is triggered if any of the following props has changed:
durationendstartIf redraw is set to true your component will start the transition on every component update.
You need to check if your counter in viewport, react-visibility-sensor can be used for this purpose.
import React from 'react';
import CountUp from 'react-countup';
import VisibilitySensor from 'react-visibility-sensor';
import './styles.css';
export default function App() {
return (
<div className="App">
<div className="content" />
<VisibilitySensor partialVisibility offset={{ bottom: 200 }}>
{({ isVisible }) => (
<div style={{ height: 100 }}>
{isVisible ? <CountUp end={1000} /> : null}
</div>
)}
</VisibilitySensor>
</div>
);
}
Note: For latest react-countup releases there are new options
enableScrollSpyandscrollSpyDelaywhich enable scroll spy, so that as user scrolls to the target element, it begins counting animation automatically once it has scrolled into view.
import './styles.css';
import CountUp, { useCountUp } from 'react-countup';
export default function App() {
useCountUp({
ref: 'counter',
end: 1234567,
enableScrollSpy: true,
scrollSpyDelay: 1000,
});
return (
<div className="App">
<div className="content" />
<CountUp end={100} enableScrollSpy />
<br />
<span id="counter" />
</div>
);
}
You can use callback properties to control accessibility:
import React from 'react';
import CountUp, { useCountUp } from 'react-countup';
export default function App() {
useCountUp({ ref: 'counter', end: 10, duration: 2 });
const [loading, setLoading] = React.useState(false);
const onStart = () => {
setLoading(true);
};
const onEnd = () => {
setLoading(false);
};
const containerProps = {
'aria-busy': loading,
};
return (
<>
<CountUp
end={123457}
duration="3"
onStart={onStart}
onEnd={onEnd}
containerProps={containerProps}
/>
<div id="counter" aria-busy={loading} />
</>
);
}
import { CountUp } from 'countup.js';
import { Odometer } from 'odometer_countup';
export default function App() {
useCountUp({
ref: 'counter',
end: 1234567,
enableScrollSpy: true,
scrollSpyDelay: 1000,
plugin: Odometer,
});
return (
<div className="App">
<span id="counter" />
</div>
);
}
MIT