14 CSS Typewriter Effect Designs 14 / 14

CSS Typewriter Scroll-Triggered Word Reveal

Words in a long-form paragraph reveal sequentially only when the element enters the viewport — IntersectionObserver triggers staggered CSS class additions for a scroll-activated typewriter effect.

CSS + JS MIT licensed
Live Demo Open in tab
Open in playground

The code

<div class="tw-14">
  <article class="tw-14__article">
    <h2 class="tw-14__heading tw-14__reveal">The art of good design.</h2>
    <p class="tw-14__body tw-14__reveal">Good design is not just about how something looks — it is about how it works, how it feels, and how it makes people feel in return. Every decision carries weight.</p>
    <p class="tw-14__body tw-14__reveal">Typography, spacing, colour, motion — each is a tool in the designer's hand. Used well, they create clarity. Used poorly, they create noise. The difference is intention.</p>
    <p class="tw-14__pullquote tw-14__reveal">Design is intelligence made visible.</p>
  </article>
</div>
.tw-14, .tw-14 *, .tw-14 *::before, .tw-14 *::after { box-sizing: border-box; margin: 0; padding: 0; }
.tw-14 ::selection { background: #0ea5e9; color: #001a28; }

.tw-14 {
  --blue: #0ea5e9;
  --sky: #38bdf8;
  --bg: #020d17;
  --text: #e0f2fe;
  --muted: #334e5e;
  font-family: 'Georgia', 'Times New Roman', serif;
  min-height: 340px;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 48px 24px;
}

.tw-14__article {
  max-width: 520px;
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.tw-14__heading {
  font-size: clamp(1.8rem, 5vw, 2.8rem);
  font-weight: 700;
  color: var(--text);
  line-height: 1.2;
}

.tw-14__body {
  font-size: 1.05rem;
  line-height: 1.8;
  color: #94a3b8;
}

.tw-14__pullquote {
  font-size: 1.2rem;
  font-style: italic;
  color: var(--sky);
  border-left: 3px solid var(--blue);
  padding-left: 20px;
}

/* Word reveal state machine */
.tw-14__reveal .tw-14-word {
  display: inline-block;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.4s ease, transform 0.4s ease;
  transition-delay: min(calc(var(--wi) * 40ms), 900ms);
}

.tw-14__reveal.visible .tw-14-word {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .tw-14__reveal .tw-14-word { opacity: 1; transform: none; transition: none; }
}
(function() {
  const targets = document.querySelectorAll('.tw-14__reveal');
  if (!targets.length) return;

  // Split text into word spans
  targets.forEach((el) => {
    const words = el.textContent.split(/(s+)/);
    el.innerHTML = words.map((part, i) => {
      if (/^s+$/.test(part)) return part;
      return `<span class="tw-14-word" style="--wi:${i}" aria-hidden="false">${part}</span>`;
    }).join('');
  });

  // Observe each element
  const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting && entry.intersectionRatio > 0) {
        entry.target.classList.add('visible');
        observer.unobserve(entry.target);
      }
    });
  }, { threshold: 0.15 });

  targets.forEach((el) => observer.observe(el));
})();

How this works

On page load, JS splits each target element's text into individual <span> word nodes, sets their initial state to opacity: 0; transform: translateY(12px), and applies a transition-delay of index × 40ms. An IntersectionObserver with a threshold: 0.2 watches each paragraph — when 20% of it enters the viewport, a .visible class is added to the paragraph, cascading the CSS transitions across all child word spans.

The .visible span rule transitions to opacity: 1; transform: none, making each word slide up into view in reading order. Because the delay is set inline as a CSS custom property, the reveal origin is always word 0 of the visible element, not word 0 of the whole page — so every paragraph replays its own stagger when it scrolls in.

Customize

  • Increase the stagger multiplier from 40ms to 60ms for longer paragraphs, or apply a logarithmic scale so late words don't wait too long: calc(log(var(--i) + 1) * 80ms).
  • Use IntersectionObserver with rootMargin: "0px 0px -100px 0px" to trigger the reveal 100px before the element reaches the viewport bottom for a more anticipatory feel.
  • Add a second observer that removes the .visible class when the element exits — enabling a replay-on-scroll loop when the user scrolls up and back down.

Watch out for

  • IntersectionObserver fires on load if elements are already in view — guard against this by checking entry.intersectionRatio > 0 before adding the class.
  • Splitting text by spaces removes \n newline context — preserve paragraph structure by processing one <p> at a time rather than splitting across elements.
  • The stagger delay accumulates to seconds on very long paragraphs — cap the maximum delay at 1s by clamping: min(calc(var(--i) * 40ms), 1000ms) in the CSS.

Browser support

ChromeSafariFirefoxEdge
58+ 12.1+ 55+ 58+

IntersectionObserver is supported in all modern browsers. Safari 12.1+ for full feature coverage.

Search CodeFronts

Loading…