iTranslated by AI
Key Considerations for Building a Number Count-up Component
Specifications
Let's try creating a simple component that counts up numbers.

Completed count-up image
- You can specify the number to count up to.
- It displays a count-up from 0 to the specified number in increments of 1.
Tech Stack Used
The tech stack used for this implementation is as follows:
-
@emotion/css: v11.10.5 -
@emotion/react: v11.10.5 -
@emotion/styled: v11.10.5 -
react: v18.2.0 -
react-dom: v18.2.0
These libraries are not strictly required; I simply used an environment that was easy for me to work in.
Also, no external libraries specifically for counting up will be used.
Implementation with CSS Only
To display a count-up, numbers need to change dynamically. By using the @property at-rule, you can implement this with just CSS without writing JavaScript.
The
@propertyrule represents the registration of a custom property directly in a stylesheet without having to run any JS. A valid@propertyrule results in a registered custom property, as ifCSS.registerPropertyhad been called with equivalent parameters.
However, at the time of writing, the @property rule is only available in Chrome (Blink)-based browsers.
Implementing the @property rule
First, define the @property rule as follows.
@property --count-number {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
Example of definition in JavaScript
Definition is also possible in JavaScript. An example of the definition is as follows.
CSS.registerProperty({
name: '--color-number',
syntax: '<integer>',
inherits: false,
initialValue: 0,
});
| Property | Description |
|---|---|
syntax |
The syntax allowed for the property |
inherits |
Whether the registration of the custom property specified with @property is inherited by default |
initial-value |
The initial value of the property |
Implementing the animation
Combine the @keyframes definition with animation.
To count up to the specified number, we'll design it to accept a value so the animation can also count up to that value.
type Props = {
maxCount: number;
};
const countAnimation = ({ maxCount }: Props) => keyframes`
from {
--count-number: 0;
}
to {
--count-number: ${maxCount};
}
`;
Next, incorporate the defined keyframes into the target element (animation).
We use the content property in a pseudo-element to display the number. Since the content property can only display strings, we use counter() to convert the number into a string.
const CountUpCss = styled.span<Props>`
--count-number: ${(props) => props.maxCount};
animation: ${countAnimation} 5000ms alternate linear;
counter-reset: counter var(--count-number);
&::after {
content: counter(counter);
}
`;
Supplement for the countAnimation part
countAnimation is specified without arguments.
animation: ${countAnimation} ${(props) => props.duration ?? 5}s alternate ease-in-out;
Since the props value is being passed to countAnimation, count can be referenced.
Trying to pass arguments would look like the following, which reduces readability, so it is defined without arguments this time.
animation: ${(props) => countAnimation({ count: props.maxCount })} 5000ms alternate linear;
How to check if @property can be used
As mentioned earlier, the @property rule is not currently available in all browsers. Therefore, it's good to include a check for its availability.
Availability can be checked using both CSS and JavaScript.
Checking with CSS
To check if CSS can use a specific property, use @supports. This is used to provide different definitions depending on the support status of implemented properties.
Since the @property rule is an at-rule and not a property, it cannot be directly checked with @supports.
Instead, we check if the @property rule is available by testing for another CSS property whose support matches whether the browser supports the @property rule or not.
/* Check for Houdini support & register property */
@supports (background: paint(something)) {
@property --gradPoint {
syntax: "<percentage>";
inherits: false;
initial-value: 40%;
}
}
Browsers that support paint() also support the @property rule, which is why this check is used. While this method is introduced on web.dev, it doesn't actually check for the @property rule itself, so it's not very intuitive and is a method I personally prefer not to use.
By the way, you can check the support status of Houdini here:
Checking with JavaScript
In JavaScript, you can check if registerProperty is available (it exists in window.CSS if supported).
const enableRegisterProperty =
// @ts-ignore
typeof window.CSS.registerProperty !== "undefined";
Since it is an experimental feature, TypeScript will issue a warning that the type does not exist. I bypassed this with @ts-ignore.
Compared to the CSS check, this method directly checks for the availability of the @property rule, making it more reliable.
Points to Note
Depending on the count-up specifications, it may not meet requirements in some cases.
- Only Chrome (Blink)-based browsers are supported (Firefox and Safari are not yet supported).
https://caniuse.com/mdn-css_at-rules_property - Strings (such as those with comma separators like "1,000") cannot be animated.
- Since numbers are displayed using pseudo-elements, the text cannot be easily copied.
Main Implementation in JavaScript
While implementing with CSS is better for performance, since it is currently only supported in specific browsers, in most cases, you will likely need to implement it in JavaScript.
The JavaScript implementation is as follows.
There is nothing particularly unusual about this implementation; it simply updates the count up to the specified value at regular intervals. For the display of the numbers, instead of using textContent, I've opted to use the display of CSS pseudo-elements (attr(data-number)), and the update method involves only updating the data attribute.
const CountUpJsInner = styled.span`
&::after {
content: attr(data-number);
}
`;
const CountUpJs = ({ maxCount }: Props) => {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
let currentCount = 0;
let requestId = -1;
const timer = () => {
const current = ref.current;
if (!current) return;
if (currentCount < maxCount) {
current.dataset.number = String(currentCount + 1);
currentCount += 1;
requestId = window.requestAnimationFrame(timer);
}
};
requestId = window.requestAnimationFrame(timer);
return () => window.cancelAnimationFrame(requestId);
}, [maxCount]);
return (
<>
<CountUpJsInner ref={ref} data-number="0" aria-hidden="true" />
<span className="sr-only">{maxCount}</span>
</>
);
};
Implementation with setInterval instead of requestAnimationFrame
const CountUpJs = ({ count }: Props) => {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
let currentCount = 0;
const timer = () => {
const current = ref.current;
if (!current) return;
if (currentCount < count) {
current.dataset.number = String(currentCount + 1);
currentCount += 1;
}
};
const timerInterval = setInterval(timer, Math.trunc(5000 / count));
return () => clearInterval(timerInterval);
}, [count]);
return <span ref={ref} data-number="0" aria-hidden="true" />;
};
Accessibility Considerations
aria-hidden
<CountUp data-number="0" aria-hidden="true"/>
<span className="sr-only">{maxCount}</span>
Since the intended number cannot be read out correctly until the count-up is complete, the count-up component itself is given aria-hidden="true" to prevent it from being read by screen readers. A separate element is provided specifically for screen readers to announce the final value.
aria-live and aria-busy
<CountUp data-number="0" aria-live="polite" aria-busy="true" />
Using these attributes would allow element updates to be announced after the current operation is finished. However, having the count-up announced while it's in progress or upon completion might be distracting, and since we already provide a dedicated element for screen readers, we decided not to use them.
Points to Note
- There is a possibility that the count-up may not finish in the expected time due to the processing load of other tasks.
- Since numbers are displayed using pseudo-elements, the text cannot be easily copied.
Others
Eliminating jitter when numbers change
Since numbers change continuously, jitter may occur depending on the specific digits or the font being used.
To resolve this issue, you can specify the font-variant-numeric property in CSS for the target element.
Using the following setting allows numbers to be displayed with tabular (monospaced) widths.
font-variant-numeric: tabular-nums;
Additionally, the font-variant-numeric property can be used to handle cases where proportional and monospaced fonts are mixed.
Display Sample:
Completion
Since CSS is more performant, I implemented it to show the CSS version in browsers that support the @property rule and the JavaScript-based component in browsers that do not.
Demo
Final Thoughts
In this article, I've covered various points I considered while creating a simple number count-up component. Although I built it as a React component, I believe the fundamental considerations remain much the same even when using plain JavaScript.
Discussion