Animating Numbers
Tags: css, javascript, webdev, tutorial
Published: 2024-05-03 09:49:44

This tutorial will look into creating a small web component for animating numbers — and all the pitfalls you need to be aware of to make it work across modern browsers.

Here's what we'll be building:

![Animated Number](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/parq6czkptfgs0jj9de2.gif)

> Disclaimer: The GIF above does not show all the steps of the animation — which looks much better!

## HTML

In HTML, we'll be using:

```html
<ui-number start="7580" end="8620" duration="2000"></ui-number>
```

Additional attributes are `iteration`, which can be either `-1` for "infinite" — or any positive integer, and `suffix`, which can be a percentage symbol, currency symbol or similar.

`duration` is in milliseconds.

Now, on to the JavaScript.

## JavaScript

The foundations of our web component is:

```js
class uiNumber extends HTMLElement {
  constructor() {
    super();
    if (!uiNumber.adopted) {
      const adopted = new CSSStyleSheet();
      adopted.replaceSync(`
        ... styles here ...
      `);
      document.adoptedStyleSheets = 
      [...document.adoptedStyleSheets, adopted];
      uiNumber.adopted = true;
    }
  }
}
uiNumber.adopted = false
customElements.define('ui-number', uiNumber);
```

`uiNumber.adopted` makes sure the global styles we need to add, are only added once. We could also have used `CSS.registerProperty` in JavaScript, but since we need to add more styles, that should only be declared once, we'll be sticking with an adopted stylesheet.

Next, we need to grab all the attributes, we declared in the HTML:

```js
const start = parseInt(this.getAttribute('start'));
const end = parseInt(this.getAttribute('end'))||1;
const iteration = parseInt(this.getAttribute('iteration'))||1;
const suffix = this.getAttribute('suffix');
const styles = [
`--num: ${start}`,
`--end: ${end}`,
`--duration: ${parseInt(this.getAttribute('duration'))||200}ms`,
`--iteration: ${iteration===-1 ? 'infinite':iteration}`,
`--timing: steps(${Math.abs(end-start)})`
]
```

Now, let's add `styles` and some helper `<span>`-tags to the shadowDOM of our component:

```js
this.attachShadow({ mode: 'open' }).innerHTML = `
<span part="number" style="${styles.join(';')}">${
  suffix ? `<span part="suffix">${suffix}</span>`:''}
</span>`;
```

Notice `part="number"` (and `part="suffix"`), which will allow us to target the element from CSS via `:host::part(number)`.

And now for some cross-browser fixes. We need to create an adopted stylesheet per _instance_ because of issues in Firefox and Safari:

```js
const stylesheet = new CSSStyleSheet();
stylesheet.replaceSync(`
  :host::part(number) {
    animation: N var(--duration, 2s) /* more */);
  }
  @keyframes N { to { --num: ${end}; } }
`);
```

The first one — the `animation` — breaks functionality in Safari, if it's moved to the global, adopted stylesheet.

The `${end}` in the keyframes should be `var(--end, 10)`, but that doesn't work in Firefox. And because an actual, unique number is inserted, the `@keyframes` cannot be moved either!

So what **can** be added to the global stylesheet? This:

```css
@property --num {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}
ui-number::part(number) { counter-reset: N var(--num); }
ui-number::part(number)::before { content: counter(N); }
```

Now, all that's left, is to add the instance stylesheet to the `shadowRoot`:

```js
this.shadowRoot.adoptedStyleSheets = [stylesheet];
```

And that's it for the web component. If you want to try it, add this to a page:

```html
Here's my <ui-number start="7580" end="8620"></ui-number> number
<script src="https://browser.style/ui/number/index.js" type="module"></script>
```

— and you'll get:

![Inline number](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i9w90s856b45s3gxs5fn.png)

The number is _inline_, and animates as soon as the instance has been mounted. Let's create a more fancy-looking component, using `animation-timeline` in CSS!

---

## Animation Timeline
First of all, let's wrap the component in some additional markup:

```html
<div class="ui-number-card">
  <ui-number start="7580" end="8620" duration="2000"></ui-number>
  <p>Millions of adults have gained literacy skills in the last decade.</p>
</div>
```

The CSS is:

```css
:where(.ui-number-card) {
  aspect-ratio: 1/1;
  background-color: #CCC;
  padding-block-end: 2ch;
  padding-inline: 2ch; 
  text-align: center;
  & p { margin: 0; }
  & ui-number {
    font-size: 500%;
    font-variant-numeric: tabular-nums;
    font-weight: 900;
    &::part(number) {
      --playstate: var(--scroll-trigger, running);
    }
    &::part(suffix) {
      font-size: 75%;
    }
  }
}
@keyframes trigger {
  to { --scroll-trigger: running; }
}
@supports (animation-timeline: view()) {
  :where(.ui-number-card) {
    --scroll-trigger: paused;
    animation: trigger linear;
    animation-range: cover;
    animation-timeline: view();
  }
}
```

Most of it is basic styling, the important part is:

```
--playstate: var(--scroll-trigger, running);
```

Here, we set the **playstate** of the animation to *another* property, that we then update in a `@keyframes`-animation on the `.ui-number-card`-wrapper. 

**That** animation is within a `@supports`-block, so we only control and run the "paused/running"-state if `animation-timeline` is actually supported (only Chrome for the moment). In other cases (Firefox and Safari), the number-animation will run immediately.

---

## Demo
You can see a [demo here](https://browser.style/ui/number/) — or you can copy/paste this snippet and play with the parameters in your own code:

```html
<!DOCTYPE html>
<html>
<head>
	<link rel="stylesheet" href="https://browser.style/base.css">
	<link rel="stylesheet" href="https://browser.style/ui/number/ui-number.css">
	<style>.ui-number-card{max-width:320px}</style>
</head>
<body>
  <div class="ui-number-card">
    <ui-number start="7580" end="8620" duration="2000"></ui-number>
    <p>Millions of adults have gained literacy skills in the last decade.</p>
  </div>
  <script src="https://browser.style/ui/number/index.js" type="module"></script>
</body>
</html>
```

## Why not just use JavaScript?

The web component requires JavaScript, so why not just use JavaScript for the number animations as well?

JavaScript is single-threaded — like a single-lane highway. The more stuff we can move to CSS (and the GPU), the faster we can go on that highway. Way better, in my opinion — and unlike real highways, there are no speed-limits!

In this case, we're just using JavaScript to init and mount the component instance/s. All the heavy lifting is done by CSS.

---

## Addendum
Thanks to @efpage I had to do some JS-based, random and colorful counters (press rerun at bottom right):

{% codepen https://codepen.io/stoumann/pen/OJGeqKa %}

Cover Photo by Mateusz Dach: https://www.pexels.com/da-dk/foto/332835/