✨ Welcome Summer Sale! Get all templates for $79 (40% Off) ✨

How to Make an Animated Number Counter with Tailwind CSS

Preview of the counter we're going to build

An animated counter is a particular type of effect applied to numeric elements to give them dynamism.

From a UX perspective, this effect is recommended for drawing the user’s attention to important data or statistics, while It’s not advised if the information is not particularly relevant as the animation requires some time to load.

At the moment we are writing this article, we have never included this element in any of our Tailwind CSS templates, so we thought it would be helpful to write a tutorial that explains how to create it and add it to your landing page or website (alternatively you can use the ready-made one we’ve coded for you in this tutorial – it’s free!).

Let’s get started!

Explaining the technique

Did you know that you can create a an animated number counter using only CSS? Well, you can! In this tutorial, we will show you how to do it with Tailwind CSS.

Before diving into the code, let’s explain the technique we’ll use to create this animation. Some time ago, creating such an animation required JavaScript. However, thanks to the @property CSS at-rule, we can now animate CSS variables. This marks a significant evolution in CSS because it allows us not only to animate numbers but also to animate letters!

In a nutshell, what we’ll do is define a CSS @property representing our number, with an initial-value. This property will be animated using a simple CSS transition. Finally, we’ll use the counter-set property to display the counter value.

Important: Before we proceed, it’s important to note that, as of our writing, this technique only works in Chrome and Chromium-based browsers like Edge and Opera. While the @property at-rule has been supported by major browsers for some time, Safari does not support the counter-set property yet, and Firefox may have issues with transitioning from the initial value to the target number. However, you can confidently use this approach in production, as it gracefully degrades to show the value without animation in non-supporting browsers.

Creating the HTML structure

OK, let’s create a section with a grid layout made of 3 blocks. Each block contains an icon, a number, a title, and a description. First, add the grid class to the section and the grid-cols-3 class to define the 3-column layout. Also, add the gap-12 class to set the spacing between columns.

<section class="grid gap-12 md:grid-cols-3 md:gap-16">
    
    <!-- Block #1 -->
    <article>
        <div class="w-14 h-14 rounded shadow-md bg-white flex justify-center items-center rotate-3 mb-6">
            <svg xmlns="http://www.w3.org/2000/svg" width="31" height="20">
                <defs>
                    <linearGradient id="icon1-a" x1="50%" x2="50%" y1="0%" y2="100%">
                        <stop offset="0%" stop-color="#A5B4FC" />
                        <stop offset="100%" stop-color="#4F46E5" />
                    </linearGradient>
                    <linearGradient id="icon1-b" x1="50%" x2="50%" y1="0%" y2="100%">
                        <stop offset="0%" stop-color="#EEF2FF" />
                        <stop offset="100%" stop-color="#C7D2FE" />
                    </linearGradient>
                </defs>
                <g fill="none" fill-rule="nonzero">
                    <path fill="url(#icon1-a)" d="M20.625 0H9.375a9.375 9.375 0 0 0 0 18.75h11.25a9.375 9.375 0 0 0 0-18.75Z" transform="translate(.885 .885)" />
                    <path fill="url(#icon1-b)" d="M9.375 17.5A8.125 8.125 0 0 1 1.25 9.375 8.125 8.125 0 0 1 9.375 1.25 8.125 8.125 0 0 1 17.5 9.375 8.125 8.125 0 0 1 9.375 17.5Z" transform="translate(.885 .885)" />
                </g>
            </svg>
        </div>
        <h2>
            <span class="flex text-slate-900 text-5xl font-extrabold mb-2">
                40K+
            </span>
            <span class="inline-flex font-semibold bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 to-indigo-300 mb-2">Variations</span>
        </h2>
        <p class="text-sm text-slate-500">Many desktop publishing packages and web page editors now use Pinky as their default model text.</p>
    </article>

    <!-- Block #2 -->
    <!-- Block #3 -->

</section>

There isn’t much to explain. The code above defines the structure of a single block. But the number is still static, so let’s see how to animate it!

Defining the counter animation with CSS

As mentioned earlier, we’ll use the @property CSS property to animate the number. Ideally, you would define this property in a separate CSS file, but for simplicity, we’ll define it directly in our HTML file. So let’s add the following code inside a <style> tag:

<style>
    @property --num {
        syntax: '<integer>';
        initial-value: 0;
        inherits: false;
    }
</style>

As you can see, we’ve defined a property called --num to represent our number. We’ve specified that the initial value is 0, and the value can only be an integer. Finally, we’ve specified that this property cannot be inherited.

Now, to animate the number, we need to define a transition. At this stage, since we want to trigger the animation on page load, we must define an animation with @keyframes. Add the following code to our <style> tag and modify our inline style as follows:

<style>
    @property --num {
        syntax: '<integer>';
        initial-value: 0;
        inherits: false;
    }
    @keyframes counter {
        from {
            --num: 0;
        }
        to {
            --num: 40;
        }
    }    
</style>

We’ve defined an animation called counter that goes from 0 to 40. Now, we need to modify the HTML part containing the number so that the animation works. Chabge our inline style as follows:

<h2>
    <span class="flex tabular-nums text-slate-900 text-5xl font-extrabold mb-2 animate-[counter_3s_ease-out_forwards] [counter-set:_num_var(--num)] before:content-[counter(num)]">
        <span class="sr-only">40</span>K+
    </span>
    <span class="inline-flex font-semibold bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 to-indigo-300 mb-2">Variations</span>
</h2>

Let’s go through the classes we’ve added:

  • animate-[counter_3s_ease-out_forwards]: This class defines the animation name, duration, and easing. It’s generated on-the-fly thanks to Tailwind CSS’s arbitrary variants.
  • [counter-set:_num_var(--num)]: This class defines the counter value, which is determined by the --num property we defined earlier.
  • before:content-[counter(num)]: This class allows us to display the number as content of the ::before pseudo-element. To use this technique, we wrap the number in a <span> and hide it from view using the sr-only class.
  • Lastly, we’ve added the tabular-nums class, which ensures that each number takes up uniform space. This CSS property is essential to maintain the block’s width consistency during the animation, resulting in a smoother visual experience.

The animation now works like a charm. However, starting the animation as soon as the page loads might not be ideal, especially if the counter is positioned at the bottom of the page. In that case, the animation would start immediately, potentially going unnoticed by the user.

Ideally, we’d want the animation to start only when the user reaches the block containing the counter. To achieve this, we’ll need a bit of JavaScript.

Trigger the animation with JavaScript

If you’ve followed our other tutorials, you might be familiar with Alpine.js. It’s a lightweight JavaScript library that allows you to add interactivity to your site without writing a single line of JavaScript. It’s incredibly easy to use, and we’ll show you how to make the animation trigger when the user scrolls to it.

Firstly, let’s add the library to our project. Insert the following code inside the <head> tag:

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

In addition to Alpine.js, we’ve also imported the @alpinejs/intersect library, which allows us to trigger an action when an element enters or exits the viewport. This is exactly what we need to trigger the counter’s animation.

The next step is to define a variable called shown, initially set to false. We’ll change this value to true when the element enters the viewport. Finally, we’ll update the class of the block containing the counter based on the value of the shown variable. Update our HTML as follows:

<h2>
    <span class="flex tabular-nums text-slate-900 text-5xl font-extrabold mb-2 transition-[_--num] duration-[3s] ease-out [counter-set:_num_var(--num)] supports-[counter-set]:before:content-[counter(num)]" x-data="{ shown: false }" x-intersect="shown = true" :class="shown && '[--num:40]'">
        <span class="supports-[counter-set]:sr-only">40</span>K+
    </span>
    <span class="inline-flex font-semibold bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 to-indigo-300 mb-2">Variations</span>
</h2>

With this done, we can remove the @keyframes at-rule since it’s the [--num:40] class that now defines the final value of the counter.

Note that we’ve also added the supports-[counter-set] class to the block containing the counter. This class allows us to display the number as content of the ::before pseudo-element only if the browser supports the counter-set property. This way, if the browser doesn’t support the property, the number will still be displayed, albeit without animation.

And there you have the final code:

<-- Inline style -->
<style>
    @property --num {
        syntax: '<integer>';
        initial-value: 0;
        inherits: false;
    }
</style>

<-- Counters -->
<section class="grid gap-12 md:grid-cols-3 md:gap-16">
    <!-- Block #1 -->
    <article>
        <div class="w-14 h-14 rounded shadow-md bg-white flex justify-center items-center rotate-3 mb-6">
            <svg xmlns="http://www.w3.org/2000/svg" width="31" height="20">
                <defs>
                    <linearGradient id="icon1-a" x1="50%" x2="50%" y1="0%" y2="100%">
                        <stop offset="0%" stop-color="#A5B4FC" />
                        <stop offset="100%" stop-color="#4F46E5" />
                    </linearGradient>
                    <linearGradient id="icon1-b" x1="50%" x2="50%" y1="0%" y2="100%">
                        <stop offset="0%" stop-color="#EEF2FF" />
                        <stop offset="100%" stop-color="#C7D2FE" />
                    </linearGradient>
                </defs>
                <g fill="none" fill-rule="nonzero">
                    <path fill="url(#icon1-a)" d="M20.625 0H9.375a9.375 9.375 0 0 0 0 18.75h11.25a9.375 9.375 0 0 0 0-18.75Z" transform="translate(.885 .885)" />
                    <path fill="url(#icon1-b)" d="M9.375 17.5A8.125 8.125 0 0 1 1.25 9.375 8.125 8.125 0 0 1 9.375 1.25 8.125 8.125 0 0 1 17.5 9.375 8.125 8.125 0 0 1 9.375 17.5Z" transform="translate(.885 .885)" />
                </g>
            </svg>
        </div>
        <h2>
            <span class="flex tabular-nums text-slate-900 text-5xl font-extrabold mb-2 transition-[_--num] duration-[3s] ease-out [counter-set:_num_var(--num)] supports-[counter-set]:before:content-[counter(num)]" x-data="{ shown: false }" x-intersect="shown = true" :class="shown && '[--num:40]'">
                <span class="supports-[counter-set]:sr-only">40</span>K+
            </span>
            <span class="inline-flex font-semibold bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 to-indigo-300 mb-2">Variations</span>
        </h2>
        <p class="text-sm text-slate-500">Many desktop publishing packages and web page editors now use Pinky as their default model text.</p>
    </article>
    <!-- Block #2 -->
    <article>
        <div class="w-14 h-14 rounded shadow-md bg-white flex justify-center items-center -rotate-3 mb-6">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="19">
                <defs>
                    <linearGradient id="icon2-a" x1="50%" x2="50%" y1="0%" y2="100%">
                        <stop offset="0%" stop-color="#A5B4FC" />
                        <stop offset="100%" stop-color="#4F46E5" />
                    </linearGradient>
                    <linearGradient id="icon2-b" x1="50%" x2="50%" y1="0%" y2="100%">
                        <stop offset="0%" stop-color="#E0E7FF" />
                        <stop offset="100%" stop-color="#A5B4FC" />
                    </linearGradient>
                </defs>
                <g fill="none" fill-rule="nonzero">
                    <path fill="url(#icon2-a)" d="M5.5 0a5.5 5.5 0 0 0 0 11c.159 0 .314-.01.469-.024a15.896 15.896 0 0 1-2.393 6.759A.5.5 0 0 0 4 18.5h1a.5.5 0 0 0 .362-.155C7.934 15.64 11 11.215 11 5.5A5.506 5.506 0 0 0 5.5 0Z" />
                    <path fill="url(#icon2-b)" d="M18.5 0a5.5 5.5 0 0 0 0 11c.159 0 .314-.01.469-.024a15.896 15.896 0 0 1-2.393 6.759.5.5 0 0 0 .424.765h1a.5.5 0 0 0 .363-.155C20.934 15.64 24 11.215 24 5.5A5.506 5.506 0 0 0 18.5 0Z" />
                </g>
            </svg>
        </div>
        <h2>
            <span class="flex tabular-nums text-slate-900 text-5xl font-extrabold mb-2 transition-[_--num] duration-[3s] ease-out [counter-set:_num_var(--num)] supports-[counter-set]:before:content-[counter(num)]" x-data="{ shown: false }" x-intersect="shown = true" :class="shown && '[--num:70]'">
                <span class="supports-[counter-set]:sr-only">70</span>K+
            </span>
            <span class="inline-flex font-semibold bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 to-indigo-300 mb-2">Lessons</span>
        </h2>
        <p class="text-sm text-slate-500">Many desktop publishing packages and web page editors now use Pinky as their default model text.</p>
    </article>
    <!-- Block #3 -->
    <article>
        <div class="w-14 h-14 rounded shadow-md bg-white flex justify-center items-center rotate-3 mb-6">
            <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26">
                <defs>
                    <radialGradient id="icon3-a" cx="68.15%" cy="27.232%" r="67.641%" fx="68.15%" fy="27.232%">
                        <stop offset="0%" stop-color="#E0E7FF" />
                        <stop offset="100%" stop-color="#A5B4FC" />
                    </radialGradient>
                </defs>
                <g fill="none" fill-rule="nonzero">
                    <circle cx="13" cy="13" r="13" fill="url(#icon3-a)" />
                    <path fill="#4F46E5" fill-opacity=".56" d="M0 13a12.966 12.966 0 0 0 4.39 9.737l1.15-1.722s.82-.237.997-.555c.554-.997-.43-2.733-.43-2.733a5.637 5.637 0 0 0-.198-1.23c-.148-.369-1.182-.874-1.182-.874S3.73 13.998 3.73 13a1.487 1.487 0 0 1 1.404-1.55 2.424 2.424 0 0 0 1.588-1.146s1.256-.332 1.551-.847c.295-.515-.332-2.36-.332-2.36a3.086 3.086 0 0 0-.012-1.481 2.8 2.8 0 0 0-.93-1.12 6.143 6.143 0 0 0-1.447-2.148A12.981 12.981 0 0 0 0 13ZM13 0c-.35 0-.696.018-1.04.045-.112.35-.695 1.248-.548 1.653.147.406 1.353.783 1.353.783s-.32 1.25.235 1.692c.554.443 1.44-.148 1.773-.037.331.111.258 2.29.258 2.29s1.07 1.181 2.124 1.33c1.053.147 2.656-1.64 2.656-1.64a21.131 21.131 0 0 0 3.448-1.102A12.974 12.974 0 0 0 13 0Z" />
                    <path fill="#6366F1" fill-opacity=".4" d="M21.398 13.848c.296.702-.555 2.494-1.256 2.843a4.76 4.76 0 0 0-1.82 1.452c-.259.406-.598 2.082-1.447 2.415-.85.332-2.863 2.228-3.934 1.932-1.071-.296-1.071-2.842-.333-3.988.441-.683-.074-2.179-.113-2.695-.039-.517-1.586-1.478-1.586-1.994 0-.813 1.772-2.955 1.772-2.955s1.453-.48 1.896-.37c.448.164.877.374 1.28.628.782.058 1.552.22 2.29.48l.848.775s2.107.777 2.403 1.477Z" />
                </g>
            </svg>

        </div>
        <h2>
            <span class="flex tabular-nums text-slate-900 text-5xl font-extrabold mb-2 transition-[_--num] duration-[3s] ease-out [counter-set:_num_var(--num)] supports-[counter-set]:before:content-[counter(num)]" x-data="{ shown: false }" x-intersect="shown = true" :class="shown && '[--num:149]'">
                <span class="supports-[counter-set]:sr-only">149</span>+
            </span>
            <span class="inline-flex font-semibold bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 to-indigo-300 mb-2">Workshops</span>
        </h2>
        <p class="text-sm text-slate-500">Many desktop publishing packages and web page editors now use Pinky as their default model text.</p>
    </article>
</section>

Conclusions

In this tutorial, we have taken an unconventional approach by using only CSS to create the counter animation. If achieving consistent results across all browsers is not a priority for you, this approach provides a more interesting alternative for this kind of stuff. Otherwise, we recommend checking out our Gray template, which includes a cross-browser compatible solution powered by JavaScript.

If you liked this tutorial, check out our entire series of Tailwind CSS tutorials or our ready-made templates if you’re looking for a pre-built interface for your next project.