Numeric Count Micro Animation

Numeric Count Micro Animation

ยท

12 min read

My book JavaScript Grammar in PDF format.

At one point Twitter implemented a micro animation to indicate that a number of comments, likes, retweets has increased or decreased.

twitter-counter-ui.gif

To see it in action head over to this codepen.io for complete source code.

Side Note 1: This tutorial is not about best coding practice but made for beginners who are interested in learning vanilla JavaScript from scratch. Code can be optimized for performance and cleaner syntax, but that often adds another layer of abstraction and distracts from simplicity of examples.

                                        • +

Side Note 2: The first version of the counter will be more detailed than the one implemented by Twitter. But there are many lessons here in particular when it comes to thinking like a UI designer before writing any code. Toward the end of this tutorial we will implement a simpler version.

I wrote this tutorial with simple modular JavaScript class that simulates Twitter's numeric counter micro animation for comments, likes and retweets.

Here's a preview of what we're going to make in this UI tutorial:

                                        • +

Building software is much more than just writing the code. Doing things right requires logistics, planning and making good implementation choices.

This UX tutorial is a step by step walkthrough for building animated number counters in vanilla JavaScript. But you should be able to easily re-implement it in your favorite framework or library (React, Vue, Angular, etc.)

I learned that it wasn't as easy as I thought it was going to be. Here's why:

  • Counter should work in both directions. User can retweet and un-retweet. The number counter should be able to increase and decrease. In each case a backward or forward animation should be played for each digit.
  • Animation should work with any amount. Increasing numbers by 1 is simple. But we can't only animate each digit individually. Going from 0 to 1 is not the same as going from 5 to 23. Surrounding digits will be affected in different ways based on the amount by which they increased or decreased.
  • Shortening numbers. 1000 should be displayed as 1K, 5712 should display as 5.7K, and million-based values like 1254009 should be displayed as 1.2M etc. This makes rotating digits in each slot a bit more challenging.
  • Maximum number of columns. Determine what is the maximum possible number of digits we need to display in numeric view.
  • Unnatural transitions. Numbers will be represented in more readable format. That means that we need to go from 999 to 1K. This is unnatural to numeric system but still our animation engine should be able to tackle this case.
  • Animating the dot character. All of our numbers are assumed to be displayed within containers sharing the same width. But for numbers containing a dot (like 125.1K) the container with the dot should be more narrow for the number to appear more natural when displayed in the user interface.

Let's start with some planning.

Choosing HTML structure for one instance of the animated numbers

There are many ways of creating this effect. You can flip numbers like an airport Split-flap display (also called flap display for short.) But in this tutorial I'll use the same exact effect Twitter uses. Both can be achieved using CSS Animations.

If you need to brush up on how to animate in CSS here's my other CSS keyframe animation tutorial that walks you through how they work. It's all pretty trivial stuff. Here is another CSS micro animation tutorial with practical examples that also might be able to help to get started.

Since we need to animate columns of numbers I decided on this simple format. Each digit will be inside a box represented by a DIV element in a flex container:

image.png

Now we need to determine the maximum number of columns for each micro animation HTML scaffold.

Numbers will be converted to more readable format. We need to figure out maximum number of columns in scaffold representing each micro animation.

As you can see from the following screenshot this is pretty much the number with maximum number of columns that can be possibly displayed because numbers like 125,165 will be converted to 125.1K (or rounded up to 125.2K, but that's a personal choice.) Aaaand... the longest possible number is:

image.png

We will also need to automatically animate container containing dot to be more narrow so the number appears to be more natural when displayed in our UI.

Each container must have 6 columns

Even smaller numbers will be padded by empty columns. This means icons will be moved to the right of the number:

image.png

Ok, it's time to actually build something!

The CSS

I use this hand-made CSS "framework" that consists of 10 flex class definitions. Over the years I narrowed down to these key classes for most of my flex designs:

.f { display: flex }
.v { align-items: center }
.vs { align-items: flex-start }
.ve { align-items: flex-end }
.h { justify-content: center }
.hs { justify-content: flex-start }
.he { justify-content: flex-end }
.r { flex-direction: row }
.c { flex-direction: column }
.s { justify-content: space-around }

To use this in my HTML all I have to do is apply classes.

To build the layout for this tutorial I created this scaffold:

<!-- html -->
<article class = "f v h c">
  <section class = "f v h">

    <!-- comments -->
    <section class = "f v h r">
      <section class = "container" id = "c1"></section>
    </section>

    <section style = "width: 25px"><!-- space --></section>

    <!-- likes -->
    <section class = "f v h r">
      <section class = "container" id = "c2"></section>
    </section>

    <section style = "width: 25px"><!-- space --></section>

    <!-- retweets -->
    <section class = "f v h r">
      <section class = "container" id = "c3"></section>
    </section>

  </section>
</article>

CSS for digit offsets

.container { /* digit container */
  overflow: hidden;     
  height: 24px;      
  display: flex;
  position: relative;      
}

.animate { max-width: 10px; transition: 0.4s }
.digit { font-weight: bold; border: 0 !important }

/* animation offsets */
.offset-0 { margin-top: 4px }
.offset-1 { margin-top: -14px }
.offset-2 { margin-top: -32px }
.offset-3 { margin-top: -50px }
.offset-4 { margin-top: -68px }
.offset-5 { margin-top: -86px }
.offset-6 { margin-top: -104px }
.offset-7 { margin-top: -122px }
.offset-8 { margin-top: -140px }
.offset-9 { margin-top: -158px }
.offset-10 { margin-top: -176px }
.offset-11 { margin-top: -170px }
.offset-12 { margin-top: -288px }
.offset-13 { margin-top: -212px } /* dot */
.offset-14 { margin-top: -232px } /* space */

CSS transforms

I used transform property to set animation speed.

How CSS transforms will be used to make the animation

  • Switching CSS classes. To create actual animations all you have to do is add or remove a CSS class. In above CSS example you can see that there were 15 classes defined with a different margin-top offset representing location of each digit in the column.
  • CSS transform property timing. Values of 0.3s and 0.4s for transform property work well for most micro animations I've worked with. I don't know what it is about them but they're neither to fast nor too slow. Perfect!
  • CSS transform vs setTimeout and .requestAnimationFrame Always use CSS transforms (or animations) instead of hacking JavaScript timers or modifying values by changing the style property. Native CSS transforms and animation engine produces generally faster and smoother results.

Flex column scaffold including all digits

This isn't the only way to create this animation. But it's one I've chosen. You could've used a background image. But images create an extra HTTP request for loading an extra resource. So I decided to create each digit as a flex item.

Creating the Numbers class

Now it's time to write some code that will take advantage of CSS transforms. We can start building our JavaScript class with a state holding the value, and its string representation. Then work out all the methods:

// Use $ and $$ instead of dinosauric document.querySelector function
let $ = selector => document.querySelector(selector);
let $$ = selector => document.querySelectorAll(selector);

// Map characters to CSS offsets
const mapping = {
  '0':0,
  '1':1,
  '2':2,
  '3':3,
  '4':4,
  '5':5,
  '6':6,
  '7':7,
  '8':8,
  '9':9,
  'K':10,
  'M':11,
  'B':12,
  '.':13,
  ' ':14,
}

const map = {
  0:'0',
  1:'1',
  2:'2',
  3:'3',
  4:'4',
  5:'5',
  6:'6',
  7:'7',
  8:'8',
  9:'9',
  10:'K',
  11:'M',
  12:'B',
  13:'.',
  14:' ',
}

/* Digit column */
const column = (pos) => `<section data-digit = '${map[pos]}' class = 'f vs hs c animate offset-${pos}'>
  <div class = "digit">0</div>
  <div class = "digit">1</div>
  <div class = "digit">2</div>
  <div class = "digit">3</div>
  <div class = "digit">4</div>
  <div class = "digit">5</div>
  <div class = "digit">6</div>
  <div class = "digit">7</div>
  <div class = "digit">8</div>
  <div class = "digit">9</div>
  <div class = "digit">K</div>
  <div class = "digit">M</div>
  <div class = "digit">.</div>
</section>`;

/* Changes 1200 to 1.2K, etc. */
const preformat_number = N => {

 // Force string format
 N = String(N);

 // Empty values are 0
 if (N.length == 0)
   return '0';

 // Nothing to change
 if (N.length == 1 ||
     N.length == 2 ||
     N.length == 3)
     return N;

 // 1s of thousands
 if (N.length == 4)
   return `${N.charAt(0)}.${N.charAt(1)}K`;

 // 10s of thousands
 if (N.length == 5)
   return `${N.charAt(0)}${N.charAt(1)}.${N.charAt(2)}K`;

 // 100s of thousands
 if (N.length == 6)
   return `${N.charAt(0)}${N.charAt(1)}${N.charAt(2)}.${N.charAt(3)}K`;

 // 1s of millions
 if (N.length == 7)
   return `${N.charAt(0)}.${N.charAt(1)}M`;

 // 10s of millions
 if (N.length == 8)
   return `${N.charAt(0)}.${N.charAt(1)}M`;

 // 100s of thousands
 if (N.length == 9)
   return `${N.charAt(0)}${N.charAt(1)}${N.charAt(2)}.${N.charAt(3)}M`;

 // 1 billion
 if (N.length == 10)
   return `${N.charAt(0)}.${N.charAt(1)}B`;
};

class Numbers {

  constructor(string_container_id, number) {

  const target = $( string_container_id );

  if (!isNaN( number )) {

   this.string_container_id = string_container_id;

   // HTML container
   this.digits = ``;

   // Initialize number
   this.number = number;

   // Convert 1000 to 1K, 1200 to 1.2K, etc.
   this.string = preformat_number(this.number);

   // Break string into individual digits
   let split = this.string.split('');

   // Create HTML scaffold with 6 columns
   for (let i = 0; i < 6; i++) {
    if (6 - i > split.length)
     this.digits += `${column(14)}`;
    else {
     const index = i-(6-split.length);
     const digit = split[index];
     this.digits += `${column(mapping[digit])}`;
    }
   }

   // Insert into target container
   target.innerHTML = this.digits;
  }
 }

 clear = index => {
   for (let x = 0; x <= 14; x++)
    $$(`${this.string_container_id} section:nth-child(${index + 1}`).forEach(item => {
     /* reset the column style*/
     item.className = `f vs hs c animate`;
     /* empty it out */
     item.classList.add(`offset-14`);
    })
 }

 update = () => {

  /* Convert 1200 to "1.2K" in string format */
  this.string = preformat_number(this.number);

  /* Apply new class */
  let split = this.string.split('');
   for (let i = 0; i < 6; i++) {
    if (6 - i > split.length) {
     this.clear(i);
    } else {
     const index = i-(6-split.length);
     const digit = split[index];
     const child = `${this.string_container_id} section:nth-child(${i + 1})`;
     const offset = `f vs hs c animate offset-${mapping[digit]}`;

     /* animate */
     $(child).className = offset;
    }
   }
 }

 increase = by => {
  /* increase the value */
  this.number += by;
  /* update the animation */
  this.update();
 }

 decrease = by => {
   /* decrease the value */
   this.number -= by;
   /* avoid negative values */
   if (this.number < 0) this.number = 0;
   /* update the animation */
   this.update();
 }

} /* end of class Numbers */

export { Numbers }

Entry-point JavaScript

<!-- Note: type "module", not "javascript" -->
<script type = "module">

  import { Numbers } from "./numbers.js";

  // Use $ and $$ instead of dinosauric document.querySelector function
  let $ = selector => document.querySelector(selector);
  let $$ = selector => document.querySelectorAll(selector);

  /* Your DOM just loaded */
  window.addEventListener('DOMContentLoaded', event => {

    /* Create numbers */
    let num1 = new Numbers(`#c1`, 0);
    let num2 = new Numbers(`#c2`, 0);
    let num3 = new Numbers(`#c3`, 0);

    /* Attach events to test buttons */
    $(`#bc1`).addEventListener("click", E => { num1.increase(1) });
    $(`#bc10`).addEventListener("click", E => { num1.increase(10) });
    $(`#bc100`).addEventListener("click", E => { num1.increase(100) });

    $(`#bl1`).addEventListener("click", E => { num2.increase(1) });
    $(`#bl10`).addEventListener("click", E => { num2.increase(10) });
    $(`#bl100`).addEventListener("click", E => { num2.increase(100) });

    $(`#br1`).addEventListener("click", E => { num3.increase(1) });
    $(`#br10`).addEventListener("click", E => { num3.increase(10) });
    $(`#br100`).addEventListener("click", E => { num3.increase(100) });

  });

  window.onload = event => {
    /* Your media (images, etc.) just loaded */
  }

</script>

Simplifying micro animation (Twitter does this)

Taking a closer look at how Twitter does it, it appears that they simply scroll two divs (one with original number and second with a newly calculated value.) This is less memory consuming, but not as cool as our first example.

There is an easier way of accomplishing a similar effect. However, it will be at expense of detail. Instead of changing each digit individually, we can simply calculate the next number and refresh the original container with a new replacement of that number.

If you take a closer look at Twitter you will see they scroll the entire number. I think it follows a rule that goes something like:

  • Animation should play only while numbers are animating. When number remains static it should be displayed as regular text. The CSS animation resources should be used only during duration of the animation.
  • Varying width. The same problem of varying width resurfaces here. In first example we solved it by forcing container to 6 columns (maximum number of digits in any possible number after shortening to a more readable notation.)

image.png

One thing to look out for is the new value might be greater or lesser in width. This can be easily solved by forcing minimum width for any value on parent container and using flex sub container that sticks to right hand side of its parent container.

To see it in action head over to this codepen.io for complete source code.

#Octopack is my coding book bundle.

Hey readers! At the end of each tutorial I advertise my coding book bundle. I don't know if it's going to work as I don't really do marketing well. But I decided to follow this protocol just to see what happens.

The pack includes CSS Dictionary, JavaScript Grammar, Python Grammar and few others. Just see if you might need any of the books based on where you are on your coding journey! Most of my books are for beginners-intermediate XP.

I don't know who is reading my tutorials but I put together this coding book bundle that contains some of the best books I've written over the years. Check it out ๐Ÿ™‚ I reduced price to some regions like India, Nigeria, Cairo and few others. Prices are just so different here in the U.S. and all over the world.

You can get Octopack by following my My Coding Books link.

css book

css flex diagram