useCountUp animates a number from a start value to an end value over a fixed duration, driven by requestAnimationFrame, and returns the current animated value on every frame. It is the hook behind the "stat counter" pattern — a dashboard number that rolls up from 0 to 1,284 when it scrolls into view instead of snapping into place. The detail that separates a correct implementation from a broken one is that the displayed value must be a function of how much time has passed, not of how many frames have rendered — so it finishes at the same wall-clock moment on a 30Hz, 60Hz, or 120Hz display.
useCountUp(
end: number,
options?: {
start?: number; // value to animate from — default 0
duration?: number; // milliseconds — default 2000
easing?: (t: number) => number; // maps progress 0..1 to an eased 0..1 — default linear
},
): number; // the current animated value
// Animate 0 -> 200 over one second (linear).
const n = useCountUp(200, { duration: 1000 });
// elapsed 0ms -> 0
// elapsed 500ms -> 100 (half the time, half the distance)
// elapsed 1000ms -> 200 (lands exactly on the end)
// elapsed 1500ms -> 200 (stays put — the loop has stopped)
// A custom ease-out curve: quick start, gentle finish.
const easeOut = (t) => 1 - (1 - t) * (1 - t);
const n = useCountUp(100, { duration: 1000, easing: easeOut });
// elapsed 500ms -> 75 (0 + 100 * easeOut(0.5))
elapsed / duration), never from a fixed per-frame increment. A += step where step assumes a 16ms frame silently drifts on any refresh rate that is not 60Hz.end — when progress reaches 1, set the value to end and stop the loop. It must never overshoot the target.end changes, cancel the running animation and start a fresh one toward the new target.cancelAnimationFrame on unmount so a dead component never calls setState.easing(t) that maps progress 0..1 to an eased 0..1; default to linear, t => t.You'll drive a number toward a target with requestAnimationFrame, reading the clock on every frame so the animation tracks elapsed time instead of how fast the screen refreshes.
A stat counter rolls a number up — 0, 47, 183, all the way to 1,284 — landing on the final figure after a second or two. The tempting way to build it is to add a little to the number on every animation frame. But "every frame" happens about 30 times a second on one laptop and 120 times a second on another. If each frame adds a fixed amount, the counter finishes four times too fast on the quick screen and overshoots the target. The fix is to make the value depend on the clock: at any instant, show from plus the fraction of the duration that has already elapsed.
Think of one frame as a tiny pipeline. It starts with the frame's timestamp, measures how long the animation has been running, turns that into a fraction between 0 and 1, optionally bends that fraction with an easing curve, and finally maps it from from to to. Every frame runs the same pipeline — the only thing that changes between frames is the elapsed time going in.
The obvious version adds a fixed step each frame. To cover to - from over duration, assuming a frame is about 16ms (60 frames per second), the step is (to - from) / (duration / 16):
const { useState, useEffect } = require('react');
function useCountUp(end, options = {}) {
const { start = 0, duration = 2000 } = options;
const [value, setValue] = useState(start);
useEffect(() => {
let current = start;
const step = (end - start) / (duration / 16); // assumes a 16ms frame
const tick = () => {
current += step; // ignores how much time actually passed
setValue(current);
if (current < end) requestAnimationFrame(tick);
};
const id = requestAnimationFrame(tick);
return () => cancelAnimationFrame(id);
}, [end, start, duration]);
return value;
}
This survives a casual glance: on a 60Hz screen it reaches roughly the target in roughly the right time. But the step is tied to the frame, not the clock. On a 120Hz display there are twice as many frames per second, so it arrives twice as fast — and because the final step jumps past to, it overshoots. On a 30Hz display it crawls in at half speed. One piece of code, three different animations.
const { useState, useRef, useEffect } = require('react');
const linear = (t) => t;
function useCountUp(end, options = {}) {
const { start = 0, duration = 2000, easing = linear } = options;
const [value, setValue] = useState(start);
// Hold the latest easing in a ref so passing an inline (t) => ... does not
// change the effect's dependencies every render and restart the animation.
const easingRef = useRef(easing);
easingRef.current = easing;
useEffect(() => {
let rafId = null;
let startTime = null;
const tick = (now) => {
// The first frame fixes the clock's zero; `now` is the rAF timestamp.
if (startTime === null) startTime = now;
const elapsed = now - startTime;
const progress = Math.min(Math.max(elapsed / duration, 0), 1);
if (progress >= 1) {
setValue(end); // land exactly on the target, then stop the loop
return;
}
setValue(start + (end - start) * easingRef.current(progress));
rafId = requestAnimationFrame(tick);
};
setValue(start); // (re)start from the beginning when end / start / duration change
rafId = requestAnimationFrame(tick);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [end, start, duration]);
return value;
}
module.exports = { useCountUp };
Three changes turn the drifting version into a correct one. First, tick reads the timestamp that requestAnimationFrame hands it (now) and records the first one as startTime, so elapsed = now - startTime is real wall-clock time. Second, progress is elapsed / duration clamped to the range 0 to 1, and the value is from + (to - from) * ease(progress) — a pure function of elapsed time, identical at any framerate. Third, the instant progress reaches 1 it sets the value to exactly end and returns without scheduling another frame, so it lands on the target and stops.
The effect re-runs whenever end, start, or duration change — that is the restart, and setValue(start) re-seeds it. Its cleanup calls cancelAnimationFrame, which covers both unmount and the moment before a restart. The easing lives in easingRef, refreshed every render, so an inline easing function never lands in the dependency array.
Take useCountUp(100, { duration: 1000 }) with the default linear easing. Only the differences between timestamps matter, so pick round numbers:
value starts at 0 (the start). The effect schedules the first frame.now = 5000 — startTime is unset, so it becomes 5000. elapsed = 0, progress = 0, value = 0 + 100 * 0 = 0. Schedule the next frame.now = 5500 — elapsed = 500, progress = 0.5, value = 0 + 100 * 0.5 = 50. Schedule the next frame.now = 6000 — elapsed = 1000, progress = 1. Set the value to exactly 100 and return. No new frame is scheduled.100, whatever the display does next.Notice that the frames could have arrived at 5000, 5003, 5871, 6000 — wildly uneven — and the value at 6000 is still exactly 100, because it is read from the elapsed time, not counted up frame by frame.
value += step bakes in 60fps; it overshoots at 120Hz and lags at 30Hz. Read the frame timestamp and compute elapsed / duration instead.to on its final frame. Guard with progress >= 1 and assign exactly end.cancelAnimationFrame — if an unmounted component's frame still fires, it calls setState on a component that is gone. Return the cleanup that cancels the pending id.easing prop restarts the animation every render (its identity changes each time). Hold it in a ref and depend only on the primitives end, start, and duration.performance.now() at schedule time — you don't need it. The frame callback already receives a DOMHighResTimeStamp; let the first frame define t = 0.end (the way react-countup's update behaves) rather than resetting to start.Intl.NumberFormat for thousands separators and fixed decimals, so 1284 renders as 1,284.IntersectionObserver so the count begins the moment the number scrolls into view.prefers-reduced-motion is set, jump straight to end and skip the animation entirely.CountUp.js (wrapped by react-countup's useCountUp) and the standalone use-count-up hook both drive the value from elapsed time exactly like this; react-use's useRaf exposes just the raw 0 to 1 progress if you would rather map it yourself.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useCountUp animates a number from a start value to an end value over a fixed duration, driven by requestAnimationFrame, and returns the current animated value on every frame. It is the hook behind the "stat counter" pattern — a dashboard number that rolls up from 0 to 1,284 when it scrolls into view instead of snapping into place. The detail that separates a correct implementation from a broken one is that the displayed value must be a function of how much time has passed, not of how many frames have rendered — so it finishes at the same wall-clock moment on a 30Hz, 60Hz, or 120Hz display.
useCountUp(
end: number,
options?: {
start?: number; // value to animate from — default 0
duration?: number; // milliseconds — default 2000
easing?: (t: number) => number; // maps progress 0..1 to an eased 0..1 — default linear
},
): number; // the current animated value
// Animate 0 -> 200 over one second (linear).
const n = useCountUp(200, { duration: 1000 });
// elapsed 0ms -> 0
// elapsed 500ms -> 100 (half the time, half the distance)
// elapsed 1000ms -> 200 (lands exactly on the end)
// elapsed 1500ms -> 200 (stays put — the loop has stopped)
// A custom ease-out curve: quick start, gentle finish.
const easeOut = (t) => 1 - (1 - t) * (1 - t);
const n = useCountUp(100, { duration: 1000, easing: easeOut });
// elapsed 500ms -> 75 (0 + 100 * easeOut(0.5))
elapsed / duration), never from a fixed per-frame increment. A += step where step assumes a 16ms frame silently drifts on any refresh rate that is not 60Hz.end — when progress reaches 1, set the value to end and stop the loop. It must never overshoot the target.end changes, cancel the running animation and start a fresh one toward the new target.cancelAnimationFrame on unmount so a dead component never calls setState.easing(t) that maps progress 0..1 to an eased 0..1; default to linear, t => t.You'll drive a number toward a target with requestAnimationFrame, reading the clock on every frame so the animation tracks elapsed time instead of how fast the screen refreshes.
A stat counter rolls a number up — 0, 47, 183, all the way to 1,284 — landing on the final figure after a second or two. The tempting way to build it is to add a little to the number on every animation frame. But "every frame" happens about 30 times a second on one laptop and 120 times a second on another. If each frame adds a fixed amount, the counter finishes four times too fast on the quick screen and overshoots the target. The fix is to make the value depend on the clock: at any instant, show from plus the fraction of the duration that has already elapsed.
Think of one frame as a tiny pipeline. It starts with the frame's timestamp, measures how long the animation has been running, turns that into a fraction between 0 and 1, optionally bends that fraction with an easing curve, and finally maps it from from to to. Every frame runs the same pipeline — the only thing that changes between frames is the elapsed time going in.
The obvious version adds a fixed step each frame. To cover to - from over duration, assuming a frame is about 16ms (60 frames per second), the step is (to - from) / (duration / 16):
const { useState, useEffect } = require('react');
function useCountUp(end, options = {}) {
const { start = 0, duration = 2000 } = options;
const [value, setValue] = useState(start);
useEffect(() => {
let current = start;
const step = (end - start) / (duration / 16); // assumes a 16ms frame
const tick = () => {
current += step; // ignores how much time actually passed
setValue(current);
if (current < end) requestAnimationFrame(tick);
};
const id = requestAnimationFrame(tick);
return () => cancelAnimationFrame(id);
}, [end, start, duration]);
return value;
}
This survives a casual glance: on a 60Hz screen it reaches roughly the target in roughly the right time. But the step is tied to the frame, not the clock. On a 120Hz display there are twice as many frames per second, so it arrives twice as fast — and because the final step jumps past to, it overshoots. On a 30Hz display it crawls in at half speed. One piece of code, three different animations.
const { useState, useRef, useEffect } = require('react');
const linear = (t) => t;
function useCountUp(end, options = {}) {
const { start = 0, duration = 2000, easing = linear } = options;
const [value, setValue] = useState(start);
// Hold the latest easing in a ref so passing an inline (t) => ... does not
// change the effect's dependencies every render and restart the animation.
const easingRef = useRef(easing);
easingRef.current = easing;
useEffect(() => {
let rafId = null;
let startTime = null;
const tick = (now) => {
// The first frame fixes the clock's zero; `now` is the rAF timestamp.
if (startTime === null) startTime = now;
const elapsed = now - startTime;
const progress = Math.min(Math.max(elapsed / duration, 0), 1);
if (progress >= 1) {
setValue(end); // land exactly on the target, then stop the loop
return;
}
setValue(start + (end - start) * easingRef.current(progress));
rafId = requestAnimationFrame(tick);
};
setValue(start); // (re)start from the beginning when end / start / duration change
rafId = requestAnimationFrame(tick);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [end, start, duration]);
return value;
}
module.exports = { useCountUp };
Three changes turn the drifting version into a correct one. First, tick reads the timestamp that requestAnimationFrame hands it (now) and records the first one as startTime, so elapsed = now - startTime is real wall-clock time. Second, progress is elapsed / duration clamped to the range 0 to 1, and the value is from + (to - from) * ease(progress) — a pure function of elapsed time, identical at any framerate. Third, the instant progress reaches 1 it sets the value to exactly end and returns without scheduling another frame, so it lands on the target and stops.
The effect re-runs whenever end, start, or duration change — that is the restart, and setValue(start) re-seeds it. Its cleanup calls cancelAnimationFrame, which covers both unmount and the moment before a restart. The easing lives in easingRef, refreshed every render, so an inline easing function never lands in the dependency array.
Take useCountUp(100, { duration: 1000 }) with the default linear easing. Only the differences between timestamps matter, so pick round numbers:
value starts at 0 (the start). The effect schedules the first frame.now = 5000 — startTime is unset, so it becomes 5000. elapsed = 0, progress = 0, value = 0 + 100 * 0 = 0. Schedule the next frame.now = 5500 — elapsed = 500, progress = 0.5, value = 0 + 100 * 0.5 = 50. Schedule the next frame.now = 6000 — elapsed = 1000, progress = 1. Set the value to exactly 100 and return. No new frame is scheduled.100, whatever the display does next.Notice that the frames could have arrived at 5000, 5003, 5871, 6000 — wildly uneven — and the value at 6000 is still exactly 100, because it is read from the elapsed time, not counted up frame by frame.
value += step bakes in 60fps; it overshoots at 120Hz and lags at 30Hz. Read the frame timestamp and compute elapsed / duration instead.to on its final frame. Guard with progress >= 1 and assign exactly end.cancelAnimationFrame — if an unmounted component's frame still fires, it calls setState on a component that is gone. Return the cleanup that cancels the pending id.easing prop restarts the animation every render (its identity changes each time). Hold it in a ref and depend only on the primitives end, start, and duration.performance.now() at schedule time — you don't need it. The frame callback already receives a DOMHighResTimeStamp; let the first frame define t = 0.end (the way react-countup's update behaves) rather than resetting to start.Intl.NumberFormat for thousands separators and fixed decimals, so 1284 renders as 1,284.IntersectionObserver so the count begins the moment the number scrolls into view.prefers-reduced-motion is set, jump straight to end and skip the animation entirely.CountUp.js (wrapped by react-countup's useCountUp) and the standalone use-count-up hook both drive the value from elapsed time exactly like this; react-use's useRaf exposes just the raw 0 to 1 progress if you would rather map it yourself.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.