FRZRBOX

Back to home

Reverse Engineer SMV typography animation

I recently saw the new website for SMV on Awwwards, and felt inspired to try recreate the scroll-based multi-directional typography kinda animation. This is pretty much a documentation of my thought process while reverse engineering.

Preview

Here is a final preview of what we will be building

See the Pen Split Text With Interaction Observer by David (@FreezerBox) on CodePen.


Concept

When building the animation, I broke it up into two steps.

1. Get the letters to animate in different directions for a single word

2. Use JS to automatically split and format the letters for me so that I can reduce markup and focus on the animation


1. Getting it working with one word

This step is mainly focused on getting the animation working, and will act as a foundation for the final step.

I start by splitting the text by letter. To do this I add an aria-label on the element with a value of the element's text.

<h1 aria-label="Hello">Hello</h1>

 

The next part is splitting the content of the element into letters. First I will create a wrapper span tag with and add aria-hidden="true". Once the wrapper is created each letter will be split into its own span tag.

<h1 aria-label="Hello">
  <span aria-hidden="true">
    <span>H</span>
    <span>E</span>
    <span>L</span>
    <span>L</span>
    <span>O</span>
  </span>
</h1>

 

NOTE: The next part may assume some knowledge, I wrote more about using custom properties in split text animations in this post.

Then I will use the style attribute on each letter to create custom properties that can be used in our animation.

I will add the following:

  • a --delay property that increments in order to create the staggering effect
  • a --direction property which will be the transform value in our animation
  • a --content property which will take the value of the current letter
<h1 aria-label="Hello">
  <span aria-hidden="true">
    <span
      style="--delay: 0s; --direction: 100%, 0, 0; --content: 'H';"
      class="letter"
      >H</span
    >
    <span
      style="--delay: 0.15s; --direction: 0, 100%, 0; --content: 'o';"
      class="letter"
      >o</span
    >
    <span
      style="--delay: 0.3s; --direction: 0, -100%, 0; --content: 'v';"
      class="letter"
      >v</span
    >
    <span
      style="--delay: 0.45s; --direction: -100%, 0, 0; --content: 'e';"
      class="letter"
      >e</span
    >
    <span
      style="--delay: 0.6s; --direction: 0, 100%, 0; --content: 'r';"
      class="letter"
      >r</span
    >
  </span>
</h1>

 

That should be it for the markup, next comes the CSS.

  • First I'll add add a font-size: 0 to the original element, this makes the letters have no gap between them.
  • Next since the letters are wrapped in span tags, I will add display: inline-block to make them animatable, followed by some other base styles that I will go more into depth with soon.

At this point our CSS should look like this

h1 {
  font-size: 0;
}

.letter {
  display: inline-block;
  position: relative;
  overflow: hidden;
  font-size: 20vw;
  color: transparent;
  font-family: Karla, sans-serif;
  font-weight: 700;
}

While some of the letter styles are purely cosmetic and can change based on the design, the ones to pay attention to are position: relative, overflow: hidden, and color: transparent. We use these styles because the span is actually going to act as a frame for the letter to animate into. The letter we see is going to be a pseudo element that will animate in. Properties position: relative and overflow: hidden give the effect of a frame by hiding anything that is outside of it. The color: transparent will keep the frame the size of the letter without making it seem like there are duplicate letters.

That last part was kinda a lot, but it will hopefully make sense soon. Now we will add the styles for the pseudo element. The content of our pseduo element will be the content custom attribute that we assigned on the inline styles for our markup. This means that each pseudo element will have the content of the current letter. Since the span tag has a color of transparent, we will only see the content of the pseudo element.

<!-- the pseudo element will have a content of 'r' -->
<span style="--content: 'r';" class="letter">r</span>
.letter:before {
  position: absolute;
  top: 0;
  left: 0;
  content: var(--content);
  color: #333;
}

 

The final part is to add the animation. Since we specified the --direction custom property on each letter, we will only have to build one keyframe animation that can be used for ever letter. Only the from needs to be specified since it will animate to translate3d(0, 0, 0) by default.

@keyframes slideIn {
  from {
    transform: translate3D(var(--direction));
  }
}

All we need to do now is to add the keyframe animation to our letter. In this case I will add it when the entire element is hovered on.

h1:hover .letter:before {
  animation: slideIn 0.45s var(--delay) cubic-bezier(0.17, 0.84, 0.44, 1) both;
}

 

This is what our final product should like

See the Pen Multi Directional Split Text by David (@FreezerBox) on CodePen.


2. Add JS to make it reusable and animate on scroll

The previous animation from step 1 is not too bad to make if there are only a couple of elements that are going to be animated. However, if we take a look at inspiration, you will notice that there are a lot of words being animated. Can you imagine what our HTML file would look like if used that same method as before for all those words?

In this step we will use JS to automate our text splitting animation from step 1 as well as animate the letter when they enter the viewport.

First we'll build a function to handle our split text animation. The function can be called handleSplit and it will take a parameter called element. I will take the element and add the aria-label, wrapper span, also all the letter span tags. It will also add the --delay and --content custom properties. The --delay property will be specified on the original element, while the --content property will by dynamically set for us.

The new markup will look like this

<!-- The letters will stagger in incrementing values of 0.1s -->
<h2 class="split" data-delay="0.1">Dynamically</h2>

The handle split function

function handleSplit(element) {
  const elementText = element.innerText;
  // Break all letters into an array
  const splitLetters = elementText.split('');
  // Add an aria label of the original content on the orignal element
  element.setAttribute('aria-label', elementText);

  // Create a wrapper for the letters
  element.innerHTML = `<span aria-hidden="true"></span>`;
  const wrapper = element.querySelector('[aria-hidden="true"]');
  // Add a span for each letter
  splitLetters.forEach((letter) => {
    // Edge case if there is a space
    if (letter === ' ') {
      letter = '&nbsp;';
    }
    wrapper.innerHTML += `
      <span class="letter">${letter}</span>
    `;
  });

  // Setup base styles
  element.style.fontSize = 0;

  element.querySelectorAll('.letter').forEach((letter, i) => {
    // Stagger delay of letter
    const letterDelay = Number(element.getAttribute('data-delay')) * i;

    letter.style = `
      display: inline-block;
      --delay: ${letterDelay}s;
      --content: '${letter.innerText}';
  `;
  });
}

 

Next we'll use the Intersection Observer to add a way to tell our animation to run when the element is in the viewport.

Note: If you aren't familiar with the Intersection Observer here is a great video explaining how it works. Although the topic is lazy-loaded images, the speaker does a great job of explaing how to implement the Intersection Observer.

Our Intersection Observer will set an attribue of data-state="in-view" when the element is intersecting and remove it once it's not. This data-state="in-view" will be the only state at which our text will animate.

/* Use Intesection Observer to animation the text when it is in view */
const observer = new IntersectionObserver((entries) => {
  entries.map((entry) => {
    console.log(entry);
    if (entry.isIntersecting) {
      entry.target.setAttribute('data-state', 'in-view');
    } else {
      entry.target.removeAttribute('data-state');
    }
  });
});

 

For our JS all that's left to do is run our observer and handleSplit function for all of the words. To do this we'll run a loop through all the elements that are going to be split and call each of our functions for every split word.

const splitElements = document.querySelectorAll('.split');

splitElements.forEach((el) => {
  handleSplit(el);
  observer.observe(el);
});

 

The final JS file will look like this

const splitElements = document.querySelectorAll('.split');

function handleSplit(element) {
  const elementText = element.innerText;
  // Break all letters into an array
  const splitLetters = elementText.split('');
  // Add an aria label of the original content on the orignal element
  element.setAttribute('aria-label', elementText);

  // Create a wrapper for the letters
  element.innerHTML = `<span aria-hidden="true"></span>`;
  const wrapper = element.querySelector('[aria-hidden="true"]');
  // Add a span for each letter
  splitLetters.forEach((letter) => {
    // Edge case if there is a space
    if (letter === ' ') {
      letter = '&nbsp;';
    }
    wrapper.innerHTML += `
      <span class="letter">${letter}</span>
    `;
  });

  // Setup base styles
  element.style.fontSize = 0;

  element.querySelectorAll('.letter').forEach((letter, i) => {
    // Stagger delay of letter
    const letterDelay = Number(element.getAttribute('data-delay')) * i;

    letter.style = `
      display: inline-block;
      --delay: ${letterDelay}s;
      --content: '${letter.innerText}';
  `;
  });
}

/* Use Intesection Observer to animation the text when it is in view */
const observer = new IntersectionObserver((entries) => {
  entries.map((entry) => {
    console.log(entry);
    if (entry.isIntersecting) {
      entry.target.setAttribute('data-state', 'in-view');
    } else {
      entry.target.removeAttribute('data-state');
    }
  });
});

splitElements.forEach((el) => {
  handleSplit(el);
  observer.observe(el);
});

 

Now that we have automated our text splitting, our markup is much simpler now. Each word only needs a class of split and a data-delay property. However if you were to inspect this in the console, you will see that it has split our text the same as the step 1 animation.

<h2 class="split" data-delay="0.1">Dynamically</h2>
<h2 class="split" data-delay="0.2">Split</h2>
<h2 class="split" data-delay="0.2">Text</h2>
<h2 class="split" data-delay="0.1">On Scroll</h2>

 

For styling we will keep the same concept of the span acting as a frame for it's pseudo element, but now the we have automated our text splitting we can easily style each word differently.

/* Create the 'frame' */
.split .letter {
  position: relative;
  color: transparent;
  overflow: hidden;
}

.letter:before {
  position: absolute;
  content: var(--content);
  height: 100%;
  width: 100%;
  color: #333;
  --direction: 0, 100%, 0;
}

/* Each word can be styled diffrently */
.split:first-of-type .letter {
  font-size: 16vw;
}
.split:nth-of-type(2) .letter {
  font-size: 40vw;
}
.split:nth-of-type(3) .letter {
  font-size: 45vw;
}
.split:nth-of-type(4) .letter {
  font-size: 22vw;
}

 

Our animation is going to use the same keyframe animation as step 1, therefore we will need to add a --direction propery. We can use :nth-child to change --direction property based off of where the letter is in the word.

Note: The following snippet is just personal preference, but it makes sure that each letter will not animate the same as the one next to it.

/* Just personal preference  just mixup so that no two animation are next to each other */

.letter:nth-child(2n):before {
  --direction: 100%, 0, 0;
}
.letter:nth-child(3n):before {
  --direction: 0, 100%, 0;
}
.letter:nth-child(4n):before {
  --direction: -100%, 0, 0;
}
.letter:nth-child(6n):before {
  --direction: 0, -100%, 0;
}

 

Finally we'll sync up the animation to our Intersection Observer by running the animation only when a letter has the data-state="in-view" attribute.

/* Animate the letters if the element's data-state is in-view  */
h2.split[data-state='in-view'] .letter:before {
  animation: slideIn 0.45s var(--delay) cubic-bezier(0.17, 0.84, 0.44, 1) both;
}

@keyframes slideIn {
  from {
    transform: translate3D(var(--direction));
  }
}

 

The final CSS file should look like this

:root {
  --ease: cubic-bezier(0.17, 0.84, 0.44, 1);
  --text-color: #333;
}

body {
  margin: 0;
  padding: 0;
  font-family: Karla, sans-serif;
}

.split .letter {
  position: relative;
  color: transparent;
  overflow: hidden;
}

.split:first-of-type .letter {
  font-size: 16vw;
}
.split:nth-of-type(2) .letter {
  font-size: 40vw;
}
.split:nth-of-type(3) .letter {
  font-size: 45vw;
}
.split:nth-of-type(4) .letter {
  font-size: 22vw;
}

.letter:before {
  position: absolute;
  content: var(--content);
  height: 100%;
  width: 100%;
  color: var(--text-color);
  --direction: 0, 100%, 0;
}

/* Just personal preference  just mixup so that no two animation are next to each other */

.letter:nth-child(2n):before {
  --direction: 100%, 0, 0;
}
.letter:nth-child(3n):before {
  --direction: 0, 100%, 0;
}
.letter:nth-child(4n):before {
  --direction: -100%, 0, 0;
}
.letter:nth-child(6n):before {
  --direction: 0, -100%, 0;
}

/* Animate the letters if the element's data-state is in-view  */
h2.split[data-state='in-view'] .letter:before {
  animation: slideIn 0.45s var(--delay) var(--ease) both;
}

@keyframes slideIn {
  from {
    transform: translate3D(var(--direction));
  }
}

Conclusion

While this example is tailored specifically for the SMV animation. You can apply the concepts for this to any split text animation that you could think of. Thanks for reading!