How to make a composited typing animation with Tailwind

, 5 min read

This is how I turned an inefficient animation into a composited one, using Tailwind and CSS knowledge. I use the animation on top of my homepage so it would have been hard for me to accept that a Cumulative Layout Shift is the top issue of my visitor's load. If it wasn't obvious (or I've changed my homepage since writing this post), the animation is the one below.

hi, I'm
vanntile 👋

The starting point

We all need to start somewhere, and this is where I have started. First of all, I am using Tailwind as a CSS framework in order to avoid reinventing the wheel whenever I need some basic utility (and I can also rely on some beautiful patterns and dark mode out of the box). But it should be as straightforward as some other code to somebody familiar with modern CSS and animations.

But first, requirements – the animation needs:

  • to betimed properly
  • to be able to have an emoji (that's because of my own copy)
  • to be responsive (to look just as good on different screen sizes)
<header className="mb-16 group">
  <h1 className="mb-1 font-mono text-4xl text-gray-100 md:text-6xl">
    hi, I'm <br className="block md:hidden" />
    <span className="inline-flex h-20 pt-2 overflow-x-hidden animate-type group-hover:animate-type-reverse whitespace-nowrap text-brand-accent will-change">
      vanntile 👋
    </span>
    <span className="box-border inline-block w-1 h-10 ml-2 -mb-2 bg-white md:-mb-4 md:h-16 animate-cursor will-change"></span>
  </h1>
  <div className="text-xl font-semibold md:text-3xl">developer by choice and designer for fun</div>
</header>

The above HTML block tries to do lots of things in the same time. First of all, it promotes the span being animated on its own layer using the will-change class, included below. This has the effect of hinting the browser to optimise that element's paint stage in the render cycle.

It's essential that we don't overdo it (you can but shouldn't promote everything as it takes processing power from the browser's allowed CPU time).

.will-change {
  will-change: transform;
}

Next, it has an animate-type class which sets an animation continuously, but that gets overriden by animate-type-reverse when the container group is hovered. Both are custom animations written by myself and change small details like duration, timing, ease and direction for a base set of keyframes, which you can see below. Yes, I went through a painstaking work of matching keyframe percentages to element width in characters.

tailwind.config.js
module.exports = {
  // ...
  theme: {
    extend: {
      animation: {
        cursor: 'cursor .6s linear infinite alternate',
        type: 'type 1.8s ease-out .8s 1 normal both',
        'type-reverse': 'type 1.8s ease-out 0s infinite alternate-reverse both',
      },
      keyframes: {
        type: {
          '0%': { width: '0ch' },
          '5%, 10%': { width: '1ch' },
          '15%, 20%': { width: '2ch' },
          '25%, 30%': { width: '3ch' },
          '35%, 40%': { width: '4ch' },
          '45%, 50%': { width: '5ch' },
          '55%, 60%': { width: '6ch' },
          '65%, 70%': { width: '7ch' },
          '75%, 80%': { width: '8ch' },
          '85%, 90%': { width: '9ch' },
          '95%': { width: '10ch' },
        },
      },
    },
  },
}

At the end of it all, there is a span element that is animated as a blinking cursor. For this, we'll animate the opacity of it's after pseudo-element, with a linear timing and an alternate direction (back and forward).

.cursor::after {
  display: block;
  content: '';
  position: absolute;
  width: 4px;
  height: 100%;
  background-color: #fff;
  animation: cursor .6s linear infinite alternate;
  will-change: opacity;
}

@keyframes cursor {
  0%, 40% {
    opacity: 1;
  }

  60%, 100% {
    opacity: 0;
  }
}

To recap, what we are doing for now is animating the width of an element with hidden overflow which both changes the layout and moves the next element's position (the cursor). To be read: It's really bad. Why? Because it triggers on all browsers all rendering steps - layout, paint and composite (which need to be recalculated).

The end result

After several iterations I have reached the following version. First, let's address the irrelevant, but necessary changes: we're changing the single quote into a &apos; and the emoji is resized depending on the screen size.

<h1 className="mb-2 font-mono text-4xl text-gray-100 md:text-6xl">
  hi, I&apos;m <br className="block md:hidden" />
  <span className="relative">
    <span className="h-20 pt-2 overflow-x-hidden whitespace-nowrap text-brand-accent">
      vanntile <span className="text-3xl md:text-5xl">👋</span>
    </span>
    <span
      className={`${styles.cursor} absolute -bottom-0 left-0 -top-1 inline-block bg-gray-900 w-full animate-type will-change`}
    ></span>
  </span>
</h1>

Afterwards, the animation logic completely changed from animating the width and hiding the rest to animating the cursor span only and translating it. Animating the transform will trigger only compositing on most browsers. In order to make our trick work we position it absolute from the relative parent and we make it full width (while positioning it slightly over the top to cover our text completely). Of course, it wouldn't work if we wouldn't it's display wouldn't be block, in order to have some width without content.

You can see in the snippet below the keyframes to the one and only animation (we have removed the complex logic that would change on hover because sometimes it's better to keep it simple, stupid).

tailwind.config.js
module.exports = {
  // ...
  theme: {
    extend: {
      animation: {
        type: 'type 2.7s ease-out .8s infinite alternate both',
      },
      keyframes: {
        type: {
          '0%': { transform: 'translateX(0ch)' },
          '5%, 10%': { transform: 'translateX(1ch)' },
          '15%, 20%': { transform: 'translateX(2ch)' },
          '25%, 30%': { transform: 'translateX(3ch)' },
          '35%, 40%': { transform: 'translateX(4ch)' },
          '45%, 50%': { transform: 'translateX(5ch)' },
          '55%, 60%': { transform: 'translateX(6ch)' },
          '65%, 70%': { transform: 'translateX(7ch)' },
          '75%, 80%': { transform: 'translateX(8ch)' },
          '85%, 90%': { transform: 'translateX(9ch)' },
          '95%, 100%': { transform: 'translateX(11ch)' },
        },
      },
    },
  },
}

Conclusion

Our browsers are incredible bundles of code nowdays and we'd better spend some more time as developers to serve a

performant, non-wasteful experience to our user, which takes advantage of the optimisations our browsers can add to the rudimentary rendering model we hold into our coding abstractions. And hey, don't forget to use Tailwind, it makes things much easier!