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.
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> <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; }
} .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));
})(); (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
40msto60msfor longer paragraphs, or apply a logarithmic scale so late words don't wait too long:calc(log(var(--i) + 1) * 80ms). - Use
IntersectionObserverwithrootMargin: "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
.visibleclass when the element exits — enabling a replay-on-scroll loop when the user scrolls up and back down.
Watch out for
IntersectionObserverfires on load if elements are already in view — guard against this by checkingentry.intersectionRatio > 0before adding the class.- Splitting text by spaces removes
\nnewline 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
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 58+ | 12.1+ | 55+ | 58+ |
IntersectionObserver is supported in all modern browsers. Safari 12.1+ for full feature coverage.