In this tutorial, we’ll create a custom animated typewriter effect you can embed in your posts, hero sections, sliders, wherever you want… All without a plugin.

We’ll use some PHP to store the settings and manage the assets, and we’ll code the animation using JavaScript/jQuery.

 
Animated typewriter effect shortcode
Typewriter Effect Shortcode

importantBefore starting this tutorial, make sure you’re using a custom child theme so you can edit functions.php.

The Specification

  • We only want the CSS/JS assets to be loaded on pages where the typewriter effect is used – not on every page.
  • Support multiple instances of the effect on a page at the same time (not just a single instance).
  • Only start animating a typewriter block as it scrolls into view. When a block scrolls out of view, stop animating it.
  • Multiple cursor types, such as a block and a thin pipe/bar.
  • Configurable cursor blink speed and typing speed.

The PHP code will be quite simple – all it needs to do is hold the parameters (typing speed, blink rate, etc), define a shortcode, and enqueue the assets. All of the hard work will be handled in JavaScript.

How It Will Work

The core component of all this is going to be a block element (default to <div>) with some data attributes, and an inline-block for the cursor.

<div class="typewriter-container" data-typewriter="{body: 'Hello World...'}">
    <span class="tw-body"><-- The text gets copied into here one character at-a-time... --></span>
    <span class="tw-cursor tw-cursor-block">&nbsp;</span>
</div>

Initialise the JavaScript

When the JavaScript starts up, we need to do some set up and initialisation:

  • Initialise our state variables.
  • Create an IntersectionObserver object to track when our typewriter blocks scroll into view (become visible), and when they scroll out-of-view.
  • The IntersectionObserver object defines a function that’s called each time an “observed object” changes visible state. The function will look like this:
    • Set newlyVisibleCount = 0 : This is the number of objects that have changed from being not visible, to being visible.
    • For each element that’s changed visible state…
      • If the element is now visible, then…
        • Increment newlyVisibleCount
        • Set the element’s “tw-is-visible” data attribute to true
        • Set the element’s “tw-cursor” data attribute to zero, so we start copying it’s body text from the first character.
      • Else…
        • Set the element’s “tw-is-visible” data attribute to false
    • If newlyVisibleCount > 0 and our core function isn’t due to be called, use requestAnimationFrame() to trigger our function at the next convenient opportunity.
  • Initialise all the typewriter blocks on the page so that “tw-is-visible” is false and “tw-cursor” is zero.
  • Connect each typewriter blocks to our IntersectionObserver. This is effectively us telling the code to “start now”.

As soon as we connect the typewriter blocks to the IntersectionObserver, the browser will start calling our observer function. This, in turn, will call our core animation function when the first typewriter block becomes visible.

Core Animation Function

We’ll use requestAnimationFrame() to run our core function every few milliseconds, when it’s convenient for the browser. This core function will have the following structure:

  • Get the current timestamp (number of milliseconds since 1st of January 1970).
  • Calculate how long it’s been since the last time we ran (deltaTime = now - lastRunTimeStamp).
  • The cursor has a blink rate of 1Hz, which means a period of 1 second (1000ms). Use deltaTime to work out how far through the period we are. e.g. if deltaTime is 120ms then we’re 12% through a period. Store this in percentageOfPeriod : 0.00 => 0% and 1.00 => 100%
  • Use percentageOfPeriod to calculate the current opacity of the cursor block(s). The opacity is a function of percentageOfPeriod, depending on the animation type (pulse, sine, square).
  • Use jQuery to select all the cursor blocks on the page and set their opacity.
  • How long has it been since we output a character on the screen? If it’s time to output a new character now, then…
    • Set the visible typewriter block count to zero.
    • For each typewriter block on the page…
      • If the block is visible, then…
        • Increment the visible typewriter block count.
        • Copy the next character from the block’s data-typewriter attribute to the end of the block’s tw-body element.
        • Increment and store the block’s cursor position.
  • If the visible typewriter block count is greater than zero then…
    • Call requestAnimationFrame() so our function will run again.
  • Else…
    • There’s nothing visible that needs to be animated, so reset the state and stop.

The Back-End PHP Code

 

To set up the project, navigate to your custom child theme’s folder, create a file called “wpt-typewriter.php” and a folder called “wpt-typewriter”. Go into the wpt-typewriter folder and create two empty files, called “typewriter.js” and “typewriter-public.css”. Paste the following into wpt-typewriter.php – this is just scaffold code to get us started.

<?php

/**
 * WP Tutorials : Typewriter (wpttw)
 *
 * https://wp-tutorials.tech/add-functionality/animated-typewriter-text-effect/
 */

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

const WPTTW_CURSOR_BLINK_RATE = 1.0; // Hz
const WPTTW_CURSOR_BLINK_TYPE = 'pulse'; // 'sine', 'square', 'pulse'
const WPTTW_CHARACTER_INTERVAL = 60; // Duration between output characters (ms).
const WPTTW_CURSOR_SHAPE = 'thin'; // 'block', 'thin'

Next, open your custom child theme’s main function.php file and add the following:

// WP Tutorials : Typewriter (WPTTYW)
require_once dirname(__FILE__) . '/wpt-typewriter.php';

That should get things up and running. Navigate to some content on your site and make sure nothing’s broken.

When you’re happy with that, replace the contents of wpt-typewriter.php with the following:

<?php

/**
 * WP Tutorials : Typewriter (WPTTYW)
 *
 * https://wp-tutorials.tech/add-functionality/animated-typewriter-text-effect/
 */

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

const WPTTW_CURSOR_BLINK_RATE = 1.0; // Hz
const WPTTW_CURSOR_BLINK_TYPE = 'pulse'; // 'sine', 'square', 'pulse'
const WPTTW_CHARACTER_INTERVAL = 60; // Time between output characters (ms).
const WPTTW_CURSOR_SHAPE = 'thin'; // 'block', 'thin'

function wpttw_enqueue_assets() {
    global $wpttw_have_assets_been_enqueued;

    if (is_null($wpttw_have_assets_been_enqueued)) {
        $handle = 'wpttw';
        $base_url = get_stylesheet_directory_uri() . '/wpt-typewriter/';
        $version = wp_get_theme()->get('Version');

        // Enqueue the styles.
        wp_enqueue_style(
            $handle,
            $base_url . 'typewriter-public.css',
            null,
            $version
        );

        // Enqueue the main JavaScript.
        wp_enqueue_script(
            $handle,
            $base_url . 'typewriter.js',
            array('jquery'),
            $version,
            true
        );

        // Make sure our script has access to a global variable called
        // wpttwData which has our settings in it.
        wp_localize_script(
            $handle,
            'wpttwData',
            array(
                'blinkRate' => WPTTW_CURSOR_BLINK_RATE,
                'blinkType' => WPTTW_CURSOR_BLINK_TYPE,
                'characterInterval' => WPTTW_CHARACTER_INTERVAL,
            )
        );

        $wpttw_have_assets_been_enqueued = true;
    }
}

function wpttw_do_shortcode_typewriter($atts) {
    $html = '';

    if (is_admin()) {
        // Don't do anything.
    } elseif (wp_doing_ajax()) {
        // Don't do anything.
    } else {
        wpttw_enqueue_assets();

        $args = shortcode_atts(
            array(
                'body' => '',
                'class' => '',
                'tag' => 'div',
                'cursor' => WPTTW_CURSOR_SHAPE,
            ),
            $atts
        );

        $cursor_class = 'tw-cursor-' . $args['cursor'];

        $css_classes = array('typewriter-container');
        if (!empty($args['class'])) {
            $css_classes[] = $args['class'];
        }

        unset($args['class']);

        $html .= sprintf(
            '<%s class="%s" data-typewriter="%s" data-tw-is-finished="false"><span class="tw-body"></span><span class="tw-cursor %s">&nbsp;</span></%s>',
            $args['tag'],
            esc_attr(implode(' ', $css_classes)),
            esc_attr(json_encode($args)),
            esc_attr($cursor_class),
            $args['tag']
        );
    }

    return $html;
}
add_shortcode('typewriter', 'wpttw_do_shortcode_typewriter');

All we’ve got in here is two simple functions… one to enqueue the typewriter assets, and the other to render the custom shortcode. Notice how the main configuration options are defined as constants, at the top of the file.

You can see the section in wpttw_do_shortcode_typewriter() that creates our main typewriter block. Notice how we don’t actually output a "<div...", instead we output a "<%s...". This is so we can change the container tag so the animation will work on <p> or <h1> elements. If we don’t specify the “tag” parameter in shortcode, we default to “div”. You can also see that the parameters for the shortcode are “body”, “class”, “tag” and “cursor”.

The JavaScript Code

Here’s the JavaScript. Just go into the “wpt-typewriter” folder and paste this into typewriter.js:

/**
 * WP Tutorials : Typewriter (WPTTYW)
 * *
 * https://wp-tutorials.tech/add-functionality/animated-typewriter-text-effect/
 *
 */
(function($) {
    'use strict';
    $(window).on('load', function() {
        console.log('WPT Typewriter : load');

        if (typeof wpttwData != 'undefined') {
            console.log('WPT Typewriter : init');

            const twoPi = Math.PI * 2;

            wpttwData.isAnimating = false;
            wpttwData.typewriters = $('[data-typewriter]');
            wpttwData.cursors = $('[data-typewriter] .tw-cursor');
            wpttwData.lastTimeStamp = 0;
            wpttwData.phase = 0;
            wpttwData.period = (1000.0 / wpttwData.blinkRate); // ms.
            wpttwData.nextCharacterTime = 0;
            wpttwData.characterInterval = 1 * wpttwData.characterInterval;

            // Create our IntersectionObserver, that starts the animation
            // function when typewriter blocks scroll into view.
            const observer = new IntersectionObserver((entries, observer) => {
                var newlyVisibleCount = 0;

                entries.forEach((entry) => {
                    if (entry.isIntersecting === true) {
                        if (!$(entry.target).data('tw-is-visible')) {
                            $(entry.target).data('tw-is-visible', true);
                            $(entry.target).data('tw-cursor', 0);
                        }
                        ++newlyVisibleCount;
                    } else {
                        $(entry.target).data('tw-is-visible', false);
                    }
                });

                if ((newlyVisibleCount > 0) && !isAnimating()) {
                    startAnimating();
                }
            }, {});

            // Initialise all typewriter blocks.
            $(wpttwData.typewriters).each(function(index, element) {
                $(element).data('tw-is-visible', false);
                $(element).data('tw-cursor', 0);

                observer.observe(element);
            });

            function isAnimating() {
                return wpttwData.isAnimating;
            }

            function startAnimating() {
                console.log('Start');
                wpttwData.isAnimating = true;
                wpttwData.lastTimeStamp = 0;
                window.requestAnimationFrame(animateFrame);
            }

            function animateFrame(timeStamp) {
                var now = Date.now();

                // If this is the first iteration, we need to set things up.
                if (!wpttwData.lastTimeStamp) {
                    wpttwData.phase = 0.0;
                    wpttwData.lastTimeStamp = now;
                    wpttwData.nextCharacterTime = (now + wpttwData.characterInterval);
                }

                // The time since our last animation frame, in ms.
                const deltaTime = now - wpttwData.lastTimeStamp;

                var opacity = 1.0;
                if ((deltaTime > 0) && (wpttwData.period > 0)) {
                    // Calculate the new phase which
                    // should be >= 0.0 and <= (2 * Math.PI)
                    var percentageOfPeriod = (deltaTime / wpttwData.period);
                    var deltaPhase = (twoPi * percentageOfPeriod);
                    wpttwData.phase += deltaPhase;
                    while (wpttwData.phase > twoPi) {
                        wpttwData.phase -= twoPi;
                    }

                    switch (wpttwData.blinkType) {
                        case 'square':
                            if (Math.sin(wpttwData.phase) < 0) {
                                opacity = 0;
                            }
                            break;

                        case 'pulse':
                            opacity = 1.0 - (wpttwData.phase / twoPi);
                            break;

                        case 'sine':
                        default:
                            opacity = 0.50 + (Math.cos(wpttwData.phase) * 0.5);
                            break;
                    }
                }

                // Make sure opacity is >= 0.0 and <= 1.0
                opacity = Math.min(Math.max(opacity, 0.0), 1.0);

                // Set all cursor blocks to the new opacity.
                $(wpttwData.cursors).css('opacity', opacity.toFixed(2));

                // How many characters do we need to output this iteration?
                // This will usually be zero or one.
                var characterCount = 0;
                while (now >= wpttwData.nextCharacterTime) {
                    wpttwData.nextCharacterTime += wpttwData.characterInterval;
                    ++characterCount;

                    if (characterCount > 5) {
                        console.log('Max character count');
                        break;
                    }
                }

                if (characterCount > 0) {
                    var visibleCount = 0;

                    // Update all visible typewriter blocks with new characters.
                    $(wpttwData.typewriters).each(function(index, element) {
                        if ($(element).data('tw-is-visible')) {
                            var cursor = $(element).data('tw-cursor');
                            var typewriterData = $(element).data('typewriter');
                            var bodyText = typewriterData.body;
                            var bodyLength = bodyText.length;

                            ++visibleCount;

                            if ((bodyLength > 0) && (cursor < bodyLength)) {
                                ++cursor;
                                $(element).find('.tw-body').text(bodyText.substring(0, cursor));
                                $(element).data('tw-cursor', cursor);
                            }
                        } else if ($(element).data('tw-cursor') > 0) {
                            $(element).find('.tw-body').text();
                            $(element).data('tw-cursor', 0);
                        } else {
                            // ...
                        }
                    });

                    if (visibleCount == 0) {
                        wpttwData.isAnimating = false;
                    }
                }

                // Diagnostics: Uncomment this if you only ever want to stop
                // executing code after a single animation frame.
                // wpttwData.isAnimating = false;

                if (!wpttwData.isAnimating) {
                    // console.log('No visible blocks - stop animating.');
                    wpttwData.lastTimeStamp = 0;
                } else {
                    // console.log(`Go Again`);
                    wpttwData.lastTimeStamp = now;
                    window.requestAnimationFrame(animateFrame);
                }
            }
        }
    });
})(jQuery);

if you’re not used to coding, this might look quite daunting, but it just breaks down into fairly standard chunks:

  • A: Set up the state variables
  • B: Initialise the page elements
  • C: Define the main function for whatever-it-is we want to do
  • D: Go…

Basic CSS Styles

 
/**
 * WP Tutorials : Typewriter (WPTTYW)
 *
 * wpt-typewriter/typewriter-public.css
 */

.typewriter-container {
    min-height: 2em;
}

.typewriter-container .tw-cursor {
    display: inline-block;
    background-color: black;
    border: 1px solid white;
}

.typewriter-container .tw-cursor.tw-cursor-thin {
    width: 0.15em;
}

.typewriter-container .tw-cursor.tw-cursor-block {
    width: 0.80em;
}

.typewriter-container.big-type {
    font-size: 48pt;
}

Using the Effect

The shortcode is the easiest way to use the effect in your site. The parameters for the shortcode are:

ParameterDefaultDescription
bodyemptyThis is the actual text you want to “write” with the effect.
classemptySpecify additional custom CSS classes for the typewriter block.
tag“div”Use a custom HTML tag for the outer typewriter block, such as “p” or “h1”.
cursor“thin”Valid values are “thin”, “block”, but you can add custom cursor styles.
Typewriter effect shortcode parameters

You can also call the typewriter function from your own code. At the top of this tutorial page, we’ve recycled some Hero Section code from our Full Width Hero Video tutorial. Instead of just outputting the page title with printf('<h1>%s</h1>", $title ), we do this:

$atts = array(
    'body' => $title,
    'tag' => 'h1',
);
echo wpttw_do_shortcode_typewriter($atts);

Calling wpttw_do_shortcode_typewriter() will automatically enqueue the JS/CSS assets. All you need to do is apply a bit of custom CSS to suit it to your site.

 

Happy typewriting! 😎 👍

Leave a Comment

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