In this article, we’ll be building a React number ticker component using React hooks and CSS animations. This component takes two numbers and animates between them when a new number is passed as a prop.
This type of animation works great when you want a subtle draw of attention to numbers that have changed.
But don’t be fooled - this animation is more complicated than it looks!
One thing to note while we're building this component - the code in the demo doesn’t support font sizes that can scale dynamically.
This article won’t be covering how to provide the component with new numbers at a set interval, as this will largely depend on how you decide to use it. For example, this component could be used to animate a number one time based on some action, or continously like a stocker ticker
Getting Started#
Before jumping in, let’s discuss how the component will work.
It should accept a prop for a number to show before each animation occurs. We’ll name it startValue
. We need another number prop for the end value, which we’ll name endValue
. Finally, we’ll have a duration
prop that defines an amount of milliseconds.
With these props, our component will then calculate the difference between each individual digit of the number, determine if each is increasing or decreasing, and position them above or below a containing element. This calculation occurs each time the endValue
updates.
For example, if the startValue
is 5654, and the endValue
is 7345, we have an array of arrays where the beginning and end of each make up the start and end values, respectively.
[['5', '6', '7'],['6', '5', '4', '3'],['5', '4'],['4', '5']]
After receiving a new number, and comparing and positioning the numbers, each column animates into place using CSS.
Finally, after the millisecond interval for the duration
prop passes, the component state updates and the endValue
number is shown until the endValue
prop updates again. This allows the number to be copy and pasted when no animation is running.
Now that we’ve discussed how the number ticker component will work, let’s write some code!
Component Setup#
First create a new component file named AnimatedNumberTicker.jsx
. Be sure to import useEffect
, useState
and useRef
from React, as we’ll leverage all three.
import { useEffect, useState, useRef } from 'react';import "./styles.css";export const AnimatedNumberTicker = ({startValue = 0,endValue,duration = 1000,}) => {const [hasMounted, setHasMounted] = useState(false);const [isReadyToAnimate, setIsReadyToAnimate] = useState(false);}
Our component has two state values: isReadyToAnimate
determines when the animation should start, and hasMounted
is a flag used to track that the component has been mounted.
The
hasMounted
state value is used to prevent the animation from running right away when the component mounts. Consider removing it if you want to support animating immediately on pageload.
Next, we’ll add three ref
values
timeoutRef
- This stores an instance of asetTimeout
, allowing us to clear it usingclearTimeout
elementRef
- This is a reference to the number shown when no animation is running, and it’s used to set the height of the number ticker containerheightRef
- This stores a height value so that we can set a static height in CSS. The height hides the vertical overflow for the animation
import { useEffect, useState, useRef } from 'react';export const AnimatedNumberTicker = (...) => {...const timeoutRef = useRef();const elementRef = useRef();const heightRef = useRef();}
Finally, we’ll return some basic HTML.
return (<span className="number-ticker"><span ref={elementRef} className="number">{endValue}</span></span>);
A span
is being used for the number-ticker
so it can be easily used inline along with other text.
If you’re using the demo code, you should already see something similar to the above where we are showing the latest number!
Diffing the Numbers#
Now let’s add the functionality that builds an array of numbers to find the difference between them.
This is the most complicated part of this article. Please refer to the demo as an example.
First, we need to get an array of strings from the startValue
and endValue
props.
export const AnimatedNumberTicker = (...) => {...const startValueArray = Array.from(String(startValue), String);const endValueArray = Array.from(String(endValue), String);return (...);}
This gives us an output like ['9', '7', '7', '4']
.
Why are we using strings here instead of numbers? This lets us support decimals and commas. For example,
['9', '7', '7', '4', '.', '0', '1']
.
Now that we have these constants, we’ll write a new function that takes in two arrays of numbers and returns an array of arrays.
export const AnimatedNumberTicker = (...) => {...const diff = difference(startValueArray, endValueArray);return (...);}
The function iterates through each item of the arrays and calls either a countUp
or countDown
function depending on the relationship between the start and end values.
At the top of our function, we want to set three let
values. More on these in a minute.
Then we clone and reverse the start
and end
parameters, so when we iterate the array, we’re moving from right to left. This lets us handle the scenario where the start
and end
numbers are of different lengths.
const difference = (start, end) => {let longerArray;let shorterArray;let isDecreasingInLength;const startReversed = [...start].reverse();const endReversed = [...end].reverse();}
Now we check the lengths and update the let
values, marking what is the longer and shorter array.
const difference = (start, end) => {...// Iterate the longer number if there's a differenceif (startReversed.length > endReversed.length) {longerArray = startReversed;shorterArray = endReversed;isDecreasingInLength = true;} else {longerArray = endReversed;shorterArray = startReversed;}}
isDecreasingInLength
should be true if we’re going from a longer number to a shorter number, for example 1000
to 900
.
Now we can use reduce
on the longer of the start or end arrays, and compare each item against the shorterArray
at the same index.
- If the items are equal, that means there’s no change between them. For example,
3001
and5681
we’d expect1
to hit this if statement since1
is in both numbers and at the same index - If the item is less than the comparison number, then we count down
- If the item is greater than the comparison number, we count up
- If the comparison number is undefined and we’re not decreasing in length, we push the original item into the returned array. This scenario would happen if
start
was500
andend
was6000
const difference = (start, end) => {...const numberColumns = longerArray.reduce((acc, item, i) => {let arr = [];const comparison = shorterArray[i];// If the items are the same, there’s been no change in the numbers.if (item === comparison) {arr = [item];} else if (item <= comparison) {arr = countDown(comparison, item);} else if (item >= comparison) {arr = countUp(comparison, item);} else if (typeof comparison === "undefined" && !isDecreasingInLength) {arr = [item];}acc.push(arr);return acc;}, []);}
We have most of the logic for the difference
function, but we’ll need the countUp
and countDown
functions referenced above.
The countUp
function takes two parameters: a start value and a max value. It returns an array of numbers beginning from the start value and ending at the max value.
const countUp = (val, max) => {const numberArray = [];for (var i = val; i <= max; i++) {numberArray.push(i);}return numberArray;}
The countDown
function is similar, just decreasing.
const countDown = (val, max) => {const numberArray = [];for (var i = val; i >= max; i--) {numberArray.push(i);}return numberArray;}
There are just two last things needed for the difference
function.
First, because we originally iterated the numbers in reverse, the numberColumns
constant should be reversed once more to get them back into the correct order.
Finally, if we’re descending between number lengths, we need to reverse each inner array as well. This is because we originally ran reduce
on the longest of the two arrays, but the longer number may not always be the end number.
For example, if we start with 1000
and end with 800
, then isDecreasingInLength
would be true.
const difference = (start, end) => {...const numberColumns = longerArray.reduce((acc, item, i) => {...}, []);const numberDiff = numberColumns.reverse();// If we are descending, reverse each individual columnif (isDecreasingInLength) {return numberDiff.map((col) => col.reverse());}return numberDiff;}
The returned output from the difference
function should be an array of arrays that we can now use to animate our number ticker component.
export const AnimatedNumberTicker = (...) => {...const diff = difference(startValueArray, endValueArray);console.log(diff);return (...);}
If you console.log()
the output of the function we just wrote, you should see something similar to below.
Setting the Component State#
Now we need to update the ticker component state when it receives new props. To do this, we’ll add two useEffect
hooks.
The first one checks if the hasMounted
state is true. If it is, it’s able to set the isReadyToAnimate
state. It does this each time the endValue
prop changes.
Otherwise, if hasMounted
is false
, then the component is mounting for the first time. In that scenario, the heightRef
is set to be the height of the static number span, and the hasMounted
state is updated.
This if/else
logic serves two purposes:
- Prevent the animation from running right when the component mounts
- Prevent continually updating the height of the
number-container
element each time theuseEffect
runs
useEffect(() => {if (hasMounted) {setIsReadyToAnimate(true);} else {heightRef.current = elementRef.current.offsetHeight;setHasMounted(true);}}, [endValue]);
Note that we’re only setting the height once. If you need a more dynamic height, you’ll need to update this logic yourself. This will be necessary if you need to support dynamic font sizes.
The second useEffect
starts a timer using setTimeout
to set the animation state back to false after the duration
prop millisecond value has passed.
useEffect(() => {if (isReadyToAnimate) {timeoutRef.current = setTimeout(() => {setIsReadyToAnimate(false);}, duration);}}, [isReadyToAnimate]);
Finishing the Component Markup#
Now that we have the diff of numbers to work with, let’s finish the markup.
First, we’ll use the heightRef
we set in the last section to set a height onto the number-ticker
.
Then, we set up a ternary operator where we only map over the numbers if isReadyToAnimate
is true
, otherwise show the endNumber
as text.
return (<spanclassName="number-ticker"style={{ height: `${heightRef.current}px` }}>{isReadyToAnimate ? (diff?.map((array, i) => {if (!array?.length) {return null;}return (<span key={i} className="number item"><span className="col">{array?.map((item) => (<span key={`${item}-${i}`}>{item}</span>))}</span></span>);})) : (<span ref={elementRef} className="number">{endValue}</span>)}</span>);
Lastly, we’ll apply a class to each of these columns depending on if the difference between each number is increasing or decreasing. A new function named determineDirection
will do that.
const determineDirection = (first, last) => {if (first < last) {return 'inc';} else if (first > last) {return 'dec';}return 'none';}
The function accepts the first and last items in the array as parameters.
{isReadyToAnimate ? (diff?.map((array, i) => {...const direction = determineDirection(array[0],array[array.length - 1]);return (<span key={i} className="number item"><span className={`col ${direction}`}>{array?.map((item) => (<span key={`${item}-${i}`}>{item}</span>))}</span></span>);})) : (<span ref={elementRef}>...</span>)}
These directional classes let us position the columns of numbers from top to bottom, or vice-versa in the next section.
Now our component should be returning output like above. All we have left is the CSS.
Base Styles#
First, we have the number-ticker
div. We’re using flexbox
pretty heavily throughout the CSS so we can easily get a nice, simple column layout.
We also use overflow: hidden
to hide the numbers that appear outside of the visible portion of the animating number.
.number-ticker {display: flex;justify-content: flex-end;overflow: hidden;position: relative;width: max-content;margin: auto;--duration: 0.5s;--easing: ease-in-out;}
Commenting out the
overflow: hidden
can be helpful for debugging because you can see the entire columns of numbers and their movements.
We set a few CSS variables above for less repetition. They’re scoped to this element so you don’t have to worry about them colliding with other styles in your project.
We want to make sure we have a line-height
set on a number
class that is applied to each number.
.number-ticker .number {line-height: 1;}
The animation we’re building works best with a constrained line height, ideally 1
. This helps prevent too much of the bottom or top of the animating numbers from becoming visible. Adding a combination of height and overflow: hidden
on the container prevents the numbers from expanding into the visible area.
Finally, we have the individual item
and col
span element styles.
.number-ticker .item {display: flex;align-items: center;flex-direction: column;}.col {display: flex;align-items: center;position: relative;}
Animation Styles#
Let’s finish up the CSS by positioning and animating the columns.
When the numbers are increasing and have the .inc
class applied, the column should animate from top to bottom.
.number-ticker .inc {top: 100%;transform: translateY(-100%);animation: increase var(--duration) var(--easing) forwards;flex-direction: column-reverse;}
To do that, a CSS keyframe named increase
transitions the top
and transform
properties to 0
.
@keyframes increase {100% {transform: translateY(0%);top: 0;}}
The code is mostly the same for each column with the .dec
class — the only difference is that we start from bottom: 0
and animate to top: 100%
.
.number-ticker .dec {bottom: 0;transform: translateY(0%);animation: decrease var(--duration) var(--easing) forwards;flex-direction: column;}@keyframes decrease {100% {top: 100%;transform: translateY(-100%);}}
To test how the number columns are working, just uncomment the overflow: hidden
property we added earlier. You should see output like below.
Our animation is complete, just be sure to reapply the overflow property!
A Note About Accessibility#
Our number ticker animation is done, but even subtle animations should have accessible alternatives. Let’s add one.
For more info about this subject, please check out Reducing Motion in Animations.
Using the prefers-reduced-motion
media query, we can easily disable the animation for users that would prefer not to see it.
@media (prefers-reduced-motion) {.number-ticker .inc,.number-ticker .dec {animation: none;}}
Summary#
This article explains how to build an animated number ticker component using React hooks and CSS keyframe animations. The component takes two numbers and provides an animation between them, which works well when numbers are frequently changing on a page. This article walks through the component setup, diffing the numbers, setting up the component state, and applying animation CSS. We also cover an accessible solution to remove the animation for users that would prefer reduced motion.