iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🥇

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 @property rule represents the registration of a custom property directly in a stylesheet without having to run any JS. A valid @property rule results in a registered custom property, as if CSS.registerProperty had been called with equivalent parameters.

https://developer.mozilla.org/en-US/docs/Web/CSS/@property

However, at the time of writing, the @property rule is only available in Chrome (Blink)-based browsers.

https://caniuse.com/mdn-css_at-rules_property

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);
  }
`;

https://css-tricks.com/animating-number-counters/


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:

https://ishoudinireadyyet.com/

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.

https://b.0218.jp/202110191552.html

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.

https://caniuse.com/font-variant-numeric

Display Sample:

https://twitter.com/javan/status/1486059026064584711

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