Building a React Number Ticker

Photo by Nick Hillier on Unsplash

Building a React Number Ticker

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.

completed number ticker

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.

numbers aligned in columns

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 a setTimeout, allowing us to clear it using clearTimeout
  • 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 container
  • heightRef - 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.

non-animated numbers changing

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 difference
if (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 and 5681 we’d expect 1 to hit this if statement since 1 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 was 500 and end was 6000
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 column
if (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.

logging output

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 hasMountedis 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:

  1. Prevent the animation from running right when the component mounts
  2. Prevent continually updating the height of the number-container element each time the useEffect 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]);

Hello! If you find this content helpful, please consider supporting this project. 🙂

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 (
<span
className="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.

diff number output

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;
}
}
non-animated numbers changing

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.