Add a revolving word to your landing page title 🎰
Tags: javascript, webdev, css
Published: 2022-07-20 18:12:46

You have surely seen this on a tech landing page before: A title where one word is periodically exchanged, almost as if they were mounted on a slot machine roll.

It is an effective building block of a landing page because it focuses attention on the most important text on that screen. The effect also does a great job highlighting multiple things your product or company has to offer. That's why I wanted it on the new landing page of my startup, [Typst.](https://typst.app/)

So let's get started: today, I'll show you how to build a title with a revolving word for yourself with only *559 Bytes* of JS (minified and gzipped). This tutorial does not require any JavaScript framework. And because "title with a revolving word" is a mouthful, I am going to call it a "Vegas Heading". Take a look at the final result:

{% codepen https://codepen.io/reknih/pen/LYdypOg %}

Let's start by gathering some of the requirements for our Vegas Heading, so we know what to build:

- We want to deploy this on a landing page with potentially lots of traffic using a variety of browsers, so we must use fairly well-supported browser features only.
- The effect should have no visible jank and play smoothly.
- The effect should be accessible and degrade gracefully when the JS fails to load or execute.
- We do not want to download a lot of data, so we cannot use a video.
- The effect should not have an influence on the layout, i.e. elements above and below the heading should be placed and sized as if the heading was "normal".
- We want to prevent inserting a lot of HTML.

So let's go!

## Markup

First, we need a `h1` tag and an element inside of it with the word that will change over time:

```html
<h1>Take <span id="change">coding</span> further</h1>
```

This looks relatively tame right now: A heading and a span inside of it. We gave it an ID so we can retrieve it in a script. Our whole animation magic will happen inside of this `span`. Over the course of this tutorial, we will add more `span`s inside of it: One for the old text and one for the new one.

Because we will insert the container for the new word programmatically via JavaScript, we only need to wrap the word coding with yet another `span`:

```html
<h1>
  Take
  <span id="change"><span class="old">coding</span></span>
  further
</h1>
```

Now we insert the new word via JavaScript and enjoy our completed markup:

```js
const container = document.getElementById("change");
const oldText = container.querySelector(".old");

const headlines = [
  oldText.innerText,
  "blogging",
  "very long words",
  "reports",
];

const newText = document.createElement("span");
// Hide it from screen readers
newText.setAttribute("aria-hidden", "true");
newText.className = "new";
newText.innerText = headlines[1];
container.insertBefore(newText, container.firstChild);
```

What sorcery are we now about to invoke to start building the animation?

Not much, actually: The whole approach is based on relative and [absolute positioning,](https://css-tricks.com/almanac/properties/p/position/#aa-absolute) `display: inline-block` and `window.requestAnimationFrame`.

When positioning an element absolutely, it is removed from the document flow and stops affecting the position of other elements. This is exactly what we want for our new text, so let's apply it and also raise it from the bottom of the page:

```css
#change .new {
  position: absolute;
  left: 0px;
  bottom: 0.9em;
}
``` 

You will surely have noticed that the word "blogging" now hovers somewhere around the very bottom of the viewport. This is because absolute positioning is relative to the closest ancestor element with `position: relative` or the document root. Therefore, we need to set `position: relative` on our outer `span`. Now "blogging" hovers right above "coding" in the heading, so far so good.

## Growing the gap

We start our animation implementation by incrementally changing the margins around the old word such that the gap will exactly fit the new one.

![A box "#change" containing the container of the old and the new word. The old word is less wide than the new word and its bounding box is centered within its parent. Around the old word's bounding box, there are arrows pointing left and right.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j2fhwxhtpf5v9ytnug05.png)

On the above image, the arrows indicate the margins around our old word `span` at the end of the animation. If `p` is the width of the old word and `n` is the width of the new word, each margin should be `(p-n) / 2` wide at the end of the animation.

But how are we running the animation? Here is where [window.requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) comes in. We can pass a callback to this method that the browser runs before the next repaint, generally as often as the screen refreshes. This ensures that our animations runs exactly as often as it needs to to appear snappy.

It is not a good idea to increment a counter to determine where in the animation playback you are. The browser might drop frames or the invocations of your animation callback might be otherwise irregular, so our animation could randomly speed up and down. Hence, we use the difference between `Date.now()` and the start time of the animation to determine how far we are in the playback.

Here is how this all comes together:

{% codepen https://codepen.io/reknih/pen/ZExKQxV %}

This pen does not look like much yet. In it, we maintain a ratio (let's call it `r`) for how much of the animation has already played and set the margins accordingly (`r * (p-n) / 2`). After the animation has completed, we change the words in the spans to the next ones from our array `headings` to prepare for the next iteration. We also have to remember to reset all the style properties we set because we  want to reset the animation.

We also had to set `white-space: nowrap;` for the whole thing to prevent the margin change from causing line breaks.

## Spice it up

You will notice that this all seems janky as hell: The new word just pops in after the resize happened. Instead, we want the old word to fade out, the new word to fade in, and both of them moving on our Vegas slot machine roll 🤑. We are starting with the cross fade:

The first thing we are adding is `opacity: 0` inside of our `#change .new` CSS rule. This will ensure that `.new` is not visible until the animation starts.

Then, inside of our animation, we have to assure two things: The opacities have to be cross-faded (they can just be `r` and `r-1` for the new and old text, respectively) and the new text has to stay centered above the old one. For this, it's `left` offset must be updated with a formula not unlike the formula for the margins: `(r-1) * (p-n) / 2`. When the margins are visible, they center the word. When they are not, the new word has to do it itself. The sign is reversed, but otherwise, we just need to ply the margin widths in reverse. It essentially has to nullify the effects of the margins on the position of the new text.

With the crossfade and repositioning, our heading then looks like this:

{% codepen https://codepen.io/reknih/pen/YzaVwaW %}

(To achieve this effect, the `bottom` property of the new text container is set to `0` instead of `0.9em`)

This already looks nice, but is missing the casino pazzaz we so desire. Hence, we add two final formulas for transitioning the bottom offset of the new (0.9 * (1-r)) and the top offset of the old text (0.9 * r).

We also need to add some CSS:

```css
#change .old {
  display: inline-block;
  position: relative;
  top: 0px;
}
```

Positioning the old text relatively and putting it in a block allows us to move it up and down in our script.

You know now how to do the magic, let's see the result again:

{% codepen https://codepen.io/reknih/pen/LYdypOg %}

Glorious, isn't it?

The sample implementation uses modern JS syntax like `const` and arrow functions as well as query selectors, but nothing is stopping you of implementing Vegas Headings for older browsers since `requestAnimationFrame` and `position: absolute` enjoy very broad browser support. Even the old cranky browser from Redmond [you should drop from your support matrix](https://blog.carlmjohnson.net/post/2020/time-to-kill-ie11/) can do it.

## What is left to do?

This implementation is good, but not perfect. There remain various ways to improve it:

- Only measure the width of both elements once and not in every frame. This keeps the animation loop lean, prevents dropped frames and keeps batteries happy.
- Stop the animation when it is not on screen. You can use an [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) for this.
- Add [`will-change`](https://developer.mozilla.org/en-US/docs/Web/CSS/will-change) to the appropriate spans to tell the browser something will happen to the element soon. This may case browsers to draw elements onto another pixel buffer so it can be moved faster.
- Honor the [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) media feature and play no or only the crossfade animation.

As you can see, you are never quite done on the web. If you want to sample a landing page with a Vegas Heading, then I recommend my startup, [Typst,](https://typst.app) whose recent landing page relaunch inspired me to do this post. Typst is a LaTeX- and Markdown-inspired online typesetting app and will launch its beta soon, we would be happy to have you on board!