Animate extruded text

Photo of Matt Perry
Matt Perry / April 2021

For better or worse, extruded text was a staple of the mid-90s desktop publishing design landscape. It was rare for a party invitation or gaming fanzine not to be blessed by this 3D text effect on its way out the printer.

As design was increasingly destined for screen rather than page, extrusion fell out of favour. But recently, I've noticed a quiet revival.

Nowadays, it's usually with a flat-shade effect of a different colour, rather than the aggressive faux 3D of the past. My favourite example so far is on the Lamanna's website:

Example of extruded text from the Lamanna's website

In this tutorial, we're going to first create this extruded text effect using CSS. Then we'll create a performant staggered animation like this:

Animateextrudedtext

We'll also discover some cross-browser issues that I bumped into while creating this technique. They have serious and surprising implications, both for the kind of extrusions we can create and the performance of animating them.

So, let's begin!

Create an extrusion effect

Extrusion can be used with any HTML, applied with any CSS selector. The only rule is that the text you apply it to is big and bold. Otherwise it won't look good!

The effect is made with the CSS text-shadow property. It can be used to draw multiple shadows, by comma-separating them:

text-shadow: -5px -5px blue, 5px 5px red;
Text shadows

By positioning these shadows at 1px increments on both axes, we can create an extrusion effect where the text appears to have depth:

text-shadow: 1px 1px red, 2px 2px red, 3px 3px red, 4px 4px red, 5px 5px red;
Text shadows

Every shadow we add gives the text an extra pixel of depth.

text-shadow: 1px 1px red, (...) 20px 20px red;
Text shadows

We can also change the direction of the effect by changing the offset of the shadows. For instance, to make text pop out from the top left, we can use increments of -1px:

text-shadow: -1px -1px red, (...) -20px -20px red;
Text shadows

At 1px increments we're drawing enough shadows to create the illusion of a smooth line in most instances. However, where text comes to sharp points, you might be able to make out jagged edges like this:

Example of aliasing using staggered drop shadows

Ideally, we would more draw shadows at smaller 0.5px increments to create some sub-pixel smoothing. This actually works in Chrome, but unfortunately Firefox and Safari round text-shadow x and y origin coordinates to the nearest pixel. Drawing extra shadows has a performance cost, so in these browsers we'd be doubling our shadow count without improving visual quality.

This origin rounding has a more serious implication. The jagged edges aren't so noticeable when shadows are drawn at 45 degree diagonals because the stepping is regular. But when trying to make other angles, the stepping becomes irregular and makes lines that look like this:

Example of jagged edges using drop shadows

This is a lot more noticeable, so for this reason I would stick to making extrusion effects at 45 degree angles unless you're specifically targeting Chrome's renderer.

Optional: Dynamic shadow generation

As we've seen, creating deeper extrusions requires adding more shadows. As these shadow lists grow they become harder to read, maintain and reuse.

Because of this, I highly recommend dynamically generating these shadows!

To do this, you'll need some kind of CSS pre-processor. The principle is the same whether you're using JavaScript, SASS, or otherwise: Create a loop that adds a shadow for every pixel of depth you want to create.

As an example, this JavaScript function will return a valid text-shadow value of the length specified by depth:

function extrude(depth) {
  let shadow = ""

  /**
   * Start the loop at 1 offset, we don't need to draw a shadow that is
   * completely obscured by the text itself.
   */
  for (let i = 1; i <= depth; i++) {
    shadow += `${i}px ${i}px #000, `
  }

  /**
   * Remove the final ", " from the string to ensure it's valid CSS shadow
   */
  return shadow.slice(0, -2)
}

This is a simple, pure function, so it's very portable and testable. You can use it in any JavaScript environment:

// DOM
element.style.textShadow = extrude(10)

// React
;<span style={{ textShadow: extrude(10) }} />

// Jest
expect(extrude(2)).toEqual("1px 1px #000, 2px 2px #000")

When you're generating shadows dynamically it's easier to be creative. For real Microsoft Publisher 97 vibes you could change the extrude function to create a darker shadow on each loop iteration:

const lightness = 50 - i * 1.5
shadow += `${i}px ${i}px hsl(200, 80%, ${lightness}%), `
Text shadows

...Okay, maybe we don't need to take it that old-school. But hopefully this gives you an idea of some of the ways you can play around with dynamic generation.

Hey there! I'm Matt Perry, and I'm creating Motion.dev to be an educational resource for motion on the web.

To stay updated with my progress, sign up to the newsletter!

Animate with CSS

CSS offers two ways to apply animations, transition and animation. A full discussion of the pros and cons of each will come in the future, but because this is a simple transition between two visual states a good rule of thumb is to use transition.

transition defines an animation to use whenever the named CSS styles change. In our case, we want to animate text-shadow, so we can apply a transition to our element like so:

h1 {
  transition: text-shadow 600ms cubic-bezier(0.22, 0.12, 0.02, 1.26);
}

The most basic syntax of transition is property time easing. Here, we're saying we want to animate text-shadow for 600 milliseconds, with the a cubic bezier curve of 0.22, 0.12, 0.02, 1.26. This curve will start the animation very fast, provide a little overshoot for that bouncy effect, before coming to rest.

With transition applied, whenever text-shadow changes on this span, it will animate. It doesn't matter how the property changes. It can be via a psuedo-selector:

h1:hover {
  text-shadow: 1px 1px pink (...);
}

Direct assignment to style via JavaScript:

// DOM
element.style.textShadow = extrude(10)

// React
;<h1 style={{ textShadow: extrude(10) }}>Animate extruded text</h1>

In this tutorial we're going to trigger the animation by adding class:

.extrude {
  text-shadow: 1px 1px pink (...);
}
// DOM
element.classList.add("extrude")

// React
<h1 className={isExtruded && "extrude"} />
Animateextrudedtext

Toggle extrude class:

So far, we're only animating text-shadow, so the extrusion looks like it's animating out from the stationary text. This can be a nice effect in its own right with shallower extrusions, but we can also make the text itself look like it's popping out of the page, by adding a transform style that moves the text by the opposite distance of the shadow.

First, we want to change our transition to animate all. This will animate any style prop that changes with the same animation.

.extrude {
  text-shadow: 1px 1px pink, ...30px 30px pink;
  transition: all 600ms cubic-bezier(0.22, 0.12, 0.02, 1.26);
}

To make the transform, out of habit I'd normally add this with translate3d. This forces the browser to hardware accelerate the animation with the GPU, usually leading to smoother motion. (You can also use will-change to do this, but it's more of a polite hint.)

.extrude {
  text-shadow: 1px 1px pink, ...30px 30px pink;
  transition: all 600ms cubic-bezier(0.22, 0.12, 0.02, 1.26);
  transform: translate3d(-30px, -30px, 0);

This looked and worked great in most browsers, but on iOS Safari, it looked like this:

I don't know exactly why this was happening, but my hunch was some kind of performance trick to reduce GPU thrashing. Which would make a likely fix changing transform to a non-hardware accelerated value:

transform: translate(-30px, -30px);

Because text-shadow already triggers layout(!) and style recalculations, the GPU acceleration probably wasn't doing anything in this instance anyway. From testing, the resultant animation performed better, even on throttled devices:

Animateextrudedtext

Toggle extrude class:

In our initial example staggered the animation of each line. To stagger the animation of different elements they each need to be wrapped in a separate tag.

<h1>
  <span>Animate</span>
  <span>extruded</span>
  <span>text</span>
</h1>

Change the class selector to apply the extrusion effect to these span children:

.extrude span {

Now we can add a separate transition-delay to each span by using the nth-child selector. We want to delay the second and third child, so we use nth-child(2) and nth-child(3):

.extrude span:nth-child(2) {
  transition-delay: 100ms;
}

.extrude span:nth-child(3) {
  transition-delay: 200ms;
}
Animateextrudedtext

Toggle extrude class:

Optional: Creating a text outline

In the original Lammana's example, the text had an outline the same color as the extrusion shadow. This makes it pop, improving readability.

They implemented this with the non-standard -webkit-text-stroke property. Despite being non-standard, I was surprised to see it actually enjoys wide compatibility with every modern browser, and has done for a long time! So I added it to our example.

-webkit-text-stroke-width: 1px;
-webkit-text-stroke-color: pink;

Check out the animation it created:

Animateextrudedtext

If you're viewing this animation in Chrome or Firefox, the animation should perform as you'd expect. But in Safari on a 2016 MacBook, it was performing poorly:

The text transform itself seems to be animating at 60fps but the shadows are being drawn in very erratically.

Recording the animation using Safari's Timelines tool was fruitless. Style, paint and layout seemed to be under control, but there was a mysterious "other" column eating up 100ms per frame.

Screenshot of Safari's useless dev tools

My heart sank because this isn't an acceptable quality for an animation and I was half way through writing this tutorial! But I remembered I had made this animation once before where it performed well, and the only discernable difference was the -webkit-text-stroke property. I turned it off and... 60fps!

I was ready to accept that you simply shouldn't use -webkit-text-stroke in animations where you're performing other paints, until I realised we're already animating up to 30 shadows to create this effect, so what's a few more?

The trick with the text outline in our finished product is that we're emulating an outline using text-shadow. To do this, add another four shadows, each offset 1px on all four diagonals:

text-shadow: 1px 1px pink, -1px 1px pink, 1px -1px pink, -1px -1px pink,
  (...) 30px 30px pink;
Animateextrudedtext

Conclusion

Now we have an extruded text animation that looks great and performs well across all browsers. There's loads of ways you can take this further too, like playing around with the dynamic shadow generator, or animating the text with JavaScript and spring physics. Let me know what you come up with!

Have you seen Motion DevTools?

It's an incredible new developer tool for Chrome that let you inspect, edit and export Motion One and CSS animations. Animate like the good-ol' days of Flash!