DEV Community

Cover image for Animating Numbers
Mads Stoumann
Mads Stoumann

Posted on

Animating Numbers

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

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

HTML

In HTML, we'll be using:

<ui-number start="7580" end="8620" duration="2000"></ui-number>
Enter fullscreen mode Exit fullscreen mode

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:

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);
Enter fullscreen mode Exit fullscreen mode

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:

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)})`
]
Enter fullscreen mode Exit fullscreen mode

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

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

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:

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

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:

@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); }
Enter fullscreen mode Exit fullscreen mode

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

this.shadowRoot.adoptedStyleSheets = [stylesheet];
Enter fullscreen mode Exit fullscreen mode

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

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

— and you'll get:

Inline number

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:

<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>
Enter fullscreen mode Exit fullscreen mode

The CSS is:

: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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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

--playstate: var(--scroll-trigger, running);
Enter fullscreen mode Exit fullscreen mode

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 — or you can copy/paste this snippet and play with the parameters in your own code:

<!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>
Enter fullscreen mode Exit fullscreen mode

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

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

Top comments (7)

Collapse
 
artydev profile image
artydev

Nice,
Here is my demo AnimatingNumbers

Collapse
 
efpage profile image
Eckehard

Javascript is mutating the DOM, but rendering takes much more than this. You will hardly see any difference speeding up the fastest part in this game...

Collapse
 
madsstoumann profile image
Mads Stoumann

True, and I agree if you look at this demo isolated.
But if you have a page with a lot of JS/interactivity, the more you can move to CSS, the better imo.

Collapse
 
efpage profile image
Eckehard

So, here is an example updating 300 buttons text and colors at different frequencies using Javascript.

Can you show any case where I can see an advantage using CSS?

Thread Thread
 
madsstoumann profile image
Mads Stoumann

Beautiful demo! But it's still not my point, sorry if I'm not being clear. If you have a modern website with carousels, sliders, click- pointer- and scroll-events, infinity scroll, api calls, partial renders, classes added and removed based on events — and all that is hapening within that single, main thread — then setTimeout is not reliable. Surely, you must have experienced websites that lagged, felt "slow" etc.? Why not put some of the burden on CSS (and the GPU)?

Thread Thread
 
efpage profile image
Eckehard

Here is another demo and clearly this is a case to let CSS do the job.

But you claim, that JS performance is a reason, why websites are slow. This simply does not meet my experience. A single HTTP request can take more time than the execution of all code on your page together, and it is very unlikely that CSS will help in this case. I do not know a single case where JS was the bottleneck, so I was asking for a demo.

Thread Thread
 
madsstoumann profile image
Mads Stoumann

All I’m saying is “split the load”: let CSS do some of the jobs, freeing JS to do more, but let’s end it here and agree to disagree!