Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { useCallback, useEffect, useRef, useState } from "react";
const DEFAULT_DURATION_MS = 750;
const easeOutQuint = t => 1 - (1 - t) ** 5;
/**
* Animates a number from one value to another on demand.
*
* Returns `displayValue` (the resting value unless an animation is running),
* `countUp(from, to)` to start one, `hold(at)` to pin the readout without
* animating, and `release()` to undo a hold. Callers keep rendering their own
* value; this only takes over for the duration of a run.
*
* @param {number} value Resting value shown when no animation is running.
* @param {number} durationMs Length of the count, in ms.
*/
export const useCountUp = (value, durationMs = DEFAULT_DURATION_MS) => {
const [animatedValue, setAnimatedValue] = useState(null);
const frameRef = useRef(null);
// Read by the running animation so a feed update mid-flight can retarget it.
const valueRef = useRef(value);
useEffect(() => {
valueRef.current = value;
}, [value]);
const cancel = useCallback(() => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
}, []);
useEffect(() => cancel, [cancel]);
const countUp = useCallback(
(from, to) => {
if (from >= to) {
return;
}
cancel();
let startTime = null;
const tick = now => {
if (startTime === null) {
startTime = now;
}
const linearT = Math.min(1, (now - startTime) / durationMs);
// Track the live value: the readout drops back to it when the run
// ends, so a fixed target would land short and then snap.
const target = Math.max(to, valueRef.current);
if (linearT < 1) {
setAnimatedValue(
Math.round(from + (target - from) * easeOutQuint(linearT))
);
frameRef.current = requestAnimationFrame(tick);
return;
}
// Hand the number back to the caller's own value rather than pinning
// it at `to`, which would go stale on the next feed update.
frameRef.current = null;
setAnimatedValue(null);
};
setAnimatedValue(from);
frameRef.current = requestAnimationFrame(tick);
},
[cancel, durationMs]
);
// Pins the readout at `at` with no animation, for a run that is deferred and
// must not show the resting value in the meantime.
const hold = useCallback(
at => {
cancel();
setAnimatedValue(at);
},
[cancel]
);
// Drops a hold when the deferred run turns out never to happen. Leaves a
// running count alone, which would otherwise flicker to the resting value.
const release = useCallback(() => {
if (frameRef.current === null) {
setAnimatedValue(null);
}
}, []);
return { countUp, displayValue: animatedValue ?? value, hold, release };
};