Animated Counter with JavaScript

Learn how to make a simple animated counter, using a WordPress PHP shortcode for the back-end, and some JavaScript for the front-end. It’s a simple way to draw attention to key statistics without being overly flashy.

We’ll write a small PHP function, a little bit of JavaScript, then style it with some CSS. We’ll also be careful to only include our CSS and JS files on pages that need them (pages with counters on them) so we don’t unnecessarily slow-down the rest our website (pages without counters on them).

--
--

We’re going to keep our PHP, JavaScript and CSS files in a self-contained module so it’s easy to use in multiple projects. What I mean is… we’re not just going to paste loads and loads of code into the theme’s main functions.php file – we’re going to keep our theme’s functions.php nice and tidy.

Get Started with Placeholder Files

In your custom child theme‘s folder, create a file called animated-counter.php and a subfolder called animated-counter. In the subfolder, create two more empty files, called afc-frontend.css and afc-frontend.js.

  • animated-counter.php (PHP code)
  • animated-counter (folder)
    • afc-frontend.css (CSS stylesheet)
    • afc-frontend.js (JavaScript code)
  • …the rest of the files in your custom child theme…

We’re going to put our custom PHP code inside a shortcode. It’s a bit of an old-fashioned way of doing things (we should really use a Gutenberg Block) but shortcodes are quick, easy and lightweight. Great for adding reusable PHP snippets like this.

Animated counter front-end asset files
The animated-counter subfolder

The PHP Code

Edit animated-counter.php and paste the following into it.

<?php

/**
 * Animated Frontend Counter.
 *
 * https://wp-tutorials.tech/add-functionality/animated-counter-with-javascript/
 */

defined('WPINC') || die();

/**
 * Timings are in milliseconds (ms). 1sec = >1000ms
 */
const ACF_ANIM_DURATION = 1500; // ms.
const ACF_ANIM_INTERVAL = 25;   // ms.

function do_shortcode_afc_counter($atts = null) {
	global $afc_have_assets_been_queued;

	// Extract the shortcodes' arguments.
	$args = shortcode_atts(
		array(
			'start' => 0,
			'end' => 100,
			'label' => null,
		),
		$atts
	);

	// Start rendering our HTML.
	$html = '<div class="afc-container">';

	// Render the optional label.
	if (!empty($args['label'])) {
		$html .= sprintf('<label>%s</label>', esc_html($args['label']));
	}

	// Render the counter.
	$html .= sprintf(
		'<span data-counter-anim="%s">--</span>',
		esc_attr(json_encode($args))
	);

	// Finish rendering our HTML.
	$html .= '</div>'; // .afc-container

	// If this is the first acf_counter shortcode then enqueue our CSS and JS
	// files, so WordPress can add them to the document footer for us.
	if (is_null($afc_have_assets_been_queued)) {
		$base_url = get_stylesheet_directory_uri() . '/' . pathinfo(__FILE__, PATHINFO_FILENAME) . '/';
		$theme_version = wp_get_theme()->get('Version');

		// Our CSS styles asset.
		wp_enqueue_style('afc-frontend', $base_url . 'afc-frontend.css', null, $theme_version);

		// Our JavaScript asset.
		wp_register_script('afc-frontend', $base_url . 'afc-frontend.js', array('jquery'), $theme_version, true);
		wp_localize_script(
			'afc-frontend',
			'acfCounters',
			array(
				'animDuration' => ACF_ANIM_DURATION,
				'animInterval' => ACF_ANIM_INTERVAL,
			)
		);
		wp_enqueue_script('afc-frontend');

		$afc_have_assets_been_queued = true;
	}

	return $html;
}
add_shortcode('afc_counter', 'do_shortcode_afc_counter');

The main function for dealing with the shortcode and creating the HTML works like this:

  1. Parse the shortcode’s arguments, like “label”, “start” and “end”.
  2. Open our containing <div>.
  3. If we’ve specified a label, then add it to the HTML now.
  4. Add a <span> element to hold the actual counter.
  5. Close the <div> container.
  6. Tell WordPress about our CSS and JavaScript files (assets) so it can add them to the document footer.

Now we’ve got a PHP function in there to render the shortcode, we need to tell our custom child theme about it. In your custom child theme, open functions.php and add the following:

/**
 * Shortcode for animated frontend counters.
 */
require_once 'animated-counter.php';

That should be enough to make things sort-of work. Try adding a shortcode block to some content. It’ll all look a bit rubbish and it won’t animate, because we’ve not written any CSS/JS, but it should confirm that the PHP side of things is plumbed-in properly.

animated counters shortcode example
Example shortcode usage

The JavaScript Animation

For the frontend JavaScript stuff (the code to animate the counters) we’re going to rely on jQuery. This will make the code easier-to-read than if we just did it 100% from scratch.

Open the afc-frontend.js file and paste the following into it:

(function($) {
    'use strict';

    $(window).on('load', function() {
        console.log('Animated Frontend Counters : load');

        // Only run our code if acfCounters has been set in the HTML by the
        // WordPress wp_localize_script() function.
        if (typeof acfCounters != 'undefined') {
            var startTime = null;
            var counterElements = [];
            var isAnimationStarted = false;

            // the "observer" responds when one of our counter animations
            // becomes 100% visible on the page.
            var observer = new IntersectionObserver(function(entries) {
                if (!isAnimationStarted) {
                    entries.forEach((entry) => {
                        if (entry.isIntersecting === true) {
                            isAnimationStarted = true;
                        }
                    });

                    if (isAnimationStarted) {
                        console.log('Starting counter animations now');
                        counterIteration();
                    }
                }
            }, { threshold: [1] });

            // Look for the <span> elements that have a data-counter-anim
            // property, stash them in the counterElements array and add them
            // to the observer.
            $('[data-counter-anim]').each(function(index, el) {
                var definition = $(this).data('counter-anim');
                counterElements.push(this);
                observer.observe($(this).closest('.afc-container')[0]);
            });

            function counterIteration() {
            	// If this is the first iteration, the set the start time to now.
                if (startTime == null) {
                    startTime = new Date();
                }

                var now = new Date();
                var timeSinceStart = now - startTime;
                var percentage = timeSinceStart / acfCounters.animDuration;
                var isLastIteration = (percentage >= 1.00);

                counterElements.forEach(counterElement => {
                    var definition = $(counterElement).data('counter-anim');
                    var counterStart = parseInt(definition.start);
                    var counterEnd = parseInt(definition.end);
                    var currentCount = counterStart;

                    if (isLastIteration) {
                        currentCount = counterEnd;
                    } else {
                        currentCount = Math.round(counterStart + (percentage * (counterEnd - counterStart)));
                    }

                    $(counterElement).text(currentCount);
                });

                if (!isLastIteration) {
                    setTimeout(counterIteration, acfCounters.animInterval);
                }
            }
        }
    });
})(jQuery);

The logic here runs like this:

  1. If the global variable acfCounters has been defined (by the wp_localize_script function in PHP) then search the DOM for elements that have a data-counter-anim property set.
    1. Stash these elements in an array called counterElements.
    2. Add each element to the observable, which will tell us when any of our counter animation <div> elements becomes visible on the page.
  2. If one or more of our counter animation elements becomes visible, and we’ve not started running the animations yet, then start now.
  3. For each animation iteration…
    1. Figure out what percentage of the animation we’re at right now, based on the current time and the time we started. 100% means we’re about to render the final frame of the animation.
    2. Loop through the elements in counterElements, figure out the current value to display, then call the element’s text() function.
    3. If we’re not at the end of the animation, run another iteration after a timeout of animInterval.

After you’ve saved the JavaScript file… reload your test page and you should see the animation working.

infoIf you don’t see the animation then open your browser’s development tools and look at your JavaScript error console. Perhaps a file name has been misspelled, so your browser can’t find your JavaScript file?

Add Some Style & Test It

The last bit is easy. Paste the following into your afc-frontend.css to get yourself started with the styling. The HTML for the counter is simply structured so it should be easy to customise it for your site.

/**
 * Animated Frontend Counters
 */

.afc-container {
    background-color: #202020;
    background-image: linear-gradient(to bottom, transparent, #303030);
    color: white;
    text-align: center;
    border-radius: 0.5rem;
    overflow: hidden;
    margin-bottom:  1rem;
}

.afc-container label {
    font-size: 16pt;
    display: block;
    letter-spacing: 0.1em;
    background-image: linear-gradient(to bottom, transparent -50%, #303030);
    border-bottom: 1px dotted #606060;
    padding: 1.25rem 0;
    opacity: 0.80;
}

.afc-container [data-counter-anim] {
    display: block;
    font-family: monospace;
    font-size: 24pt;
    letter-spacing: 0.1em;
    padding: 1.25rem;
}

That should do it. Save the changes to your CSS file, reload your test content and you should be sorted. If something’s not working, then keep an eye on your browser’s JavaScript console for errors… missing files and typos.

Happy counter-ing! 👍

Leave a Comment

Your email address will not be published. Required fields are marked *