How to make a headline type itself out

Build a headline that types itself out letter by letter with a blinking cursor, using plain HTML, CSS and a few lines of JavaScript.

Illustration of a headline being typed letter by letter with a pink blinking cursor

In this tutorial you'll build a headline that types itself out one letter at a time, with a little blinking cursor at the end. It's the same effect the homepage headline on this site uses. We'll keep it light: plain HTML, a bit of CSS, a short JavaScript loop, and a few small tricks so the page doesn't jump, screen readers get the full sentence, and people who prefer less motion just see the text.

Start with the markup

The headline has three layers. A visually hidden copy of the sentence is what screen readers and search engines read. A hidden ghost copy holds the space. And the live layer is the part that actually types, with the cursor right after it.

Notice that the live text already contains the full sentence in the HTML. If JavaScript fails or is switched off, people still see a normal headline. The script clears it and types it back in. The whole visual part gets aria-hidden="true" so screen readers don't hear the sentence twice, or worse, hear it one letter at a time.

The visually-hidden class is the classic pattern: the text stays in the page and in the accessibility tree, it just takes up no visible space.

HTML
<h1 class="typing-title">
  <span class="visually-hidden">Hello, I build friendly websites.</span>
  <span class="typing-box" aria-hidden="true">
    <span class="typing-ghost">Hello, I build friendly websites.</span>
    <span class="typing-live"><span class="typing-text">Hello, I build friendly websites.</span><span class="typing-cursor"></span></span>
  </span>
</h1>
CSS
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Hold the space so nothing jumps

If the headline starts empty and grows, it pushes everything below it down as each new line wraps. That's layout shift, and it feels jumpy. The fix is to always reserve room for the finished sentence.

Here the ghost and the live layer sit in the same CSS grid cell, stacked on top of each other. The ghost has the full text with visibility: hidden, so it keeps its size but you never see it. The box is always as tall as the finished headline, at every screen width, with no guessed heights. Grid stacking like this works in all modern browsers.

CSS
.typing-title {
  font-size: clamp(2rem, 6vw, 4rem);
  line-height: 1.1;
}

.typing-box {
  display: grid;
}

/* Both layers sit in the same grid cell, on top of each other */
.typing-ghost
.typing-live {
  grid-area: 1 / 1;
}

/* Takes up the full space but is never seen */
.typing-ghost {
  visibility: hidden;
  padding-right: 6px; /* room for the cursor */
}

Make the cursor blink

The cursor is just an empty span shaped like a thin bar. Setting its height in em units means it scales with the font size, and the small negative vertical-align lines it up with the letters instead of the baseline.

The steps(1) timing is the trick for a real on and off blink. Without it, the opacity would fade smoothly, which looks more like a glow than a text cursor. For reduced motion we turn the blink off and leave a steady bar.

CSS
.typing-cursor {
  display: inline-block;
  width: 3px;
  height: 0.82em;
  margin-left: 3px;
  background: #e0457b;
  vertical-align: -0.08em;
  animation: cursor-blink 1s steps(1) infinite;
}

@keyframes cursor-blink {
  0% 49% { opacity: 1; }
  50% 100% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .typing-cursor {
    animation: none;
  }
}

Write the typing loop

The loop is tiny. Grab the sentence, clear the text, then add one more letter every 55 milliseconds with setTimeout until it's all there. A short delay before the first letter gives the page a moment to settle.

The reduced motion check comes first. If someone has asked their system for less motion, the script does nothing at all, so the full sentence that was already in the HTML just stays put. Put this script at the end of the body, right before the closing body tag, so the headline exists when it runs.

HTML
<script>
  const output = document.querySelector('.typing-text');
  const letters = Array.from(output.textContent);
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const TYPE_SPEED_MS = 55;
  const START_DELAY_MS = 300;

  if (!reduceMotion) {
    let count = 0;
    output.textContent = '';

    function typeNext() {
      count += 1;
      output.textContent = letters.slice(0, count).join('');
      if (count < letters.length) {
        setTimeout(typeNext, TYPE_SPEED_MS);
      }
    }

    setTimeout(typeNext, START_DELAY_MS);
  }
</script>

Rotate through a few phrases

Want the headline to type, pause, delete and try a new ending? That's what the homepage here does. The loop just needs to remember which phrase it's on, how many letters are showing, and whether it's typing or deleting. Each call to tick() moves one step and schedules the next.

Two things change in the markup. The visually hidden text lists every phrase, so screen reader users get all of them at once. And the ghost should hold the longest phrase, so the box is tall enough for any of them.

A loop that keeps going counts as moving content. WCAG's Pause, Stop, Hide guideline asks for a way to pause anything that moves for more than five seconds, so you can stop after one round or add a small pause button.

HTML
<h1 class="typing-title">
  <span class="visually-hidden">Hello, I build friendly websites, tiny web apps, and things people enjoy using.</span>
  <span class="typing-box" aria-hidden="true">
    <span class="typing-ghost">Hello, I build things people enjoy using.</span>
    <span class="typing-live"><span class="typing-text">Hello, I build friendly websites.</span><span class="typing-cursor"></span></span>
  </span>
</h1>
HTML
<script>
  const output = document.querySelector('.typing-text');
  const prefix = 'Hello, I build ';
  const phrases = ['friendly websites.', 'tiny web apps.', 'things people enjoy using.'];
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  const TYPE_SPEED_MS = 55;
  const DELETE_SPEED_MS = 30;
  const HOLD_FULL_MS = 1800;
  const HOLD_EMPTY_MS = 400;

  let phraseIndex = 0;
  let length = 0;
  let deleting = false;

  function tick() {
    const current = phrases[phraseIndex];

    if (!deleting && length < current.length) {
      length += 1;
      output.textContent = prefix + current.slice(0, length);
      setTimeout(tick, TYPE_SPEED_MS);
      return;
    }

    if (!deleting) {
      deleting = true;
      setTimeout(tick, HOLD_FULL_MS);
      return;
    }

    if (length > 0) {
      length -= 1;
      output.textContent = prefix + current.slice(0, length);
      setTimeout(tick, DELETE_SPEED_MS);
      return;
    }

    deleting = false;
    phraseIndex = (phraseIndex + 1) % phrases.length;
    setTimeout(tick, HOLD_EMPTY_MS);
  }

  if (!reduceMotion) {
    output.textContent = prefix;
    tick();
  }
</script>

A quick note for React

In React you can store the typed text in state and let an effect add one letter per render. Always return a cleanup that clears the timeout, otherwise a fast re-render or an unmount can leave stray timers typing into nothing. The accessibility bits stay the same: full text for screen readers, aria-hidden on the animated part.

CSS
useEffect(() => {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    setTyped(fullText);
    return;
  }
  if (typed.length < fullText.length) {
    const t = setTimeout(() => setTyped(fullText.slice(0, typed.length + 1)), 55);
    return () => clearTimeout(t);
  }
} [typed fullText]);

One last tip: if your headline has emoji or accented letters built from more than one character, split it with Array.from() like we did in the typing loop. A plain .slice() on the string works on code units, so it can cut an emoji in half for one frame and show a broken symbol.