Web Animations Medium
Text Reveal on Scroll
Per-character and per-line text animations triggered on scroll with GSAP SplitText.
Open in Lab
MCP
gsap splittext scrolltrigger
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #070a12;
--text: #f0f4fb;
--muted: #8a95a8;
--accent: #86e8ff;
--neon-purple: #ae52ff;
}
html {
background: var(--bg);
color: var(--text);
font-family: "Inter", "SF Pro Display", system-ui, sans-serif;
}
body {
overflow-x: hidden;
}
.section {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
}
.hero-section {
min-height: 80vh;
}
.content {
max-width: 800px;
width: 100%;
}
.label {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
margin-bottom: 1.5rem;
}
h1,
h2 {
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
margin-bottom: 1.5rem;
}
h1 {
font-size: clamp(2.5rem, 7vw, 5.5rem);
background: linear-gradient(135deg, #fff, var(--accent), var(--neon-purple));
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
h2 {
font-size: clamp(2rem, 5vw, 4rem);
}
.split-sub {
font-size: clamp(0.95rem, 2vw, 1.15rem);
color: var(--muted);
max-width: 480px;
line-height: 1.6;
}
p.split-lines {
font-size: clamp(0.95rem, 1.8vw, 1.1rem);
color: var(--muted);
line-height: 1.7;
max-width: 600px;
}
/* Quote section */
.section-quote {
background: linear-gradient(135deg, rgba(174, 82, 255, 0.06), rgba(134, 232, 255, 0.04));
}
.quote-text {
font-size: clamp(1.8rem, 4vw, 3.2rem);
font-weight: 600;
font-style: italic;
letter-spacing: -0.02em;
line-height: 1.25;
color: var(--accent);
border: none;
max-width: 700px;
}
/* Back link */
.back-link {
display: inline-block;
margin-top: 2rem;
padding: 0.7rem 2rem;
border-radius: 999px;
border: 1px solid rgba(134, 232, 255, 0.3);
color: var(--accent);
text-decoration: none;
font: 600 0.85rem / 1 "Inter", system-ui, sans-serif;
transition: all 0.25s ease;
}
.back-link:hover {
background: rgba(134, 232, 255, 0.08);
border-color: var(--accent);
}
/* SplitText char/word/line wrappers get these from GSAP */
.char,
.word,
.line {
display: inline-block;
will-change: transform, opacity;
}
/* Clip overflow on lines for reveal effect */
.line-wrapper {
overflow: hidden;
display: block;
}
/* Reduced motion */
.reduced-motion .char,
.reduced-motion .word,
.reduced-motion .line,
.reduced-motion h1,
.reduced-motion h2,
.reduced-motion p,
.reduced-motion blockquote,
.reduced-motion .label,
.reduced-motion .split-sub {
opacity: 1 !important;
transform: none !important;
}
@media (max-width: 640px) {
.section {
padding: 3rem 1.5rem;
}
}if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger, SplitText);
// ── Demo shell ──
initDemoShell({
title: "Text Reveal on Scroll",
category: "scroll",
tech: ["gsap", "splittext", "scrolltrigger"],
});
// ── Lenis ──
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
const reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add("reduced-motion");
window.addEventListener("motion-preference", (e) => {
document.documentElement.classList.toggle("reduced-motion", e.detail.reduced);
ScrollTrigger.refresh();
});
const dur = (d) => (reduced ? 0 : d);
// ── Hero entrance (no scroll trigger) ──
const heroLabel = document.querySelector(".hero-section .label");
const heroH1 = document.querySelector(".hero-section h1");
const heroSub = document.querySelector(".hero-section .split-sub");
gsap.set([heroLabel, heroSub], { opacity: 0, y: reduced ? 0 : 20 });
const heroSplit = new SplitText(heroH1, { type: "chars", charsClass: "char" });
gsap.set(heroSplit.chars, { opacity: 0, y: reduced ? 0 : 40, rotateX: reduced ? 0 : -60 });
const heroTl = gsap.timeline({ delay: 0.4 });
heroTl
.to(heroLabel, { opacity: 1, y: 0, duration: dur(0.6), ease: "expo.out" })
.to(
heroSplit.chars,
{
opacity: 1,
y: 0,
rotateX: 0,
duration: dur(0.6),
ease: "back.out(1.4)",
stagger: { each: 0.03, from: "start" },
},
"-=0.3"
)
.to(heroSub, { opacity: 1, y: 0, duration: dur(0.7), ease: "expo.out" }, "-=0.4");
// ── Character reveals ──
document.querySelectorAll(".split-chars:not(.hero-section h1)").forEach((el) => {
const split = new SplitText(el, { type: "chars", charsClass: "char" });
gsap.set(split.chars, {
opacity: 0,
y: reduced ? 0 : 50,
rotateX: reduced ? 0 : -45,
});
gsap.to(split.chars, {
opacity: 1,
y: 0,
rotateX: 0,
duration: dur(0.5),
ease: "back.out(1.2)",
stagger: { each: 0.025, from: "start" },
scrollTrigger: {
trigger: el,
start: "top 80%",
toggleActions: "play none none reverse",
},
});
});
// ── Word reveals ──
document.querySelectorAll(".split-words").forEach((el) => {
const split = new SplitText(el, { type: "words", wordsClass: "word" });
gsap.set(split.words, {
opacity: 0,
y: reduced ? 0 : 35,
scale: reduced ? 1 : 0.95,
});
gsap.to(split.words, {
opacity: 1,
y: 0,
scale: 1,
duration: dur(0.6),
ease: "expo.out",
stagger: { each: 0.06, from: "start" },
scrollTrigger: {
trigger: el,
start: "top 80%",
toggleActions: "play none none reverse",
},
});
});
// ── Line reveals ──
document.querySelectorAll(".split-lines").forEach((el) => {
const split = new SplitText(el, { type: "lines", linesClass: "line" });
gsap.set(split.lines, {
opacity: 0,
y: reduced ? 0 : 30,
});
gsap.to(split.lines, {
opacity: 1,
y: 0,
duration: dur(0.7),
ease: "power3.out",
stagger: { each: 0.1, from: "start" },
scrollTrigger: {
trigger: el,
start: "top 85%",
toggleActions: "play none none reverse",
},
});
});
// ── Line heading reveals ──
document.querySelectorAll(".split-lines-heading").forEach((el) => {
const split = new SplitText(el, { type: "lines", linesClass: "line" });
gsap.set(split.lines, {
opacity: 0,
x: reduced ? 0 : -40,
});
gsap.to(split.lines, {
opacity: 1,
x: 0,
duration: dur(0.8),
ease: "expo.out",
stagger: { each: 0.12, from: "start" },
scrollTrigger: {
trigger: el,
start: "top 75%",
toggleActions: "play none none reverse",
},
});
});
// ── Scrub-linked character reveal ──
document.querySelectorAll(".scrub-chars").forEach((el) => {
const split = new SplitText(el, { type: "chars", charsClass: "char" });
gsap.set(split.chars, {
opacity: 0.15,
color: "#263249",
});
gsap.to(split.chars, {
opacity: 1,
color: "#f0f4fb",
duration: dur(0.3),
stagger: { each: 0.03, from: "start" },
scrollTrigger: {
trigger: el,
start: "top 70%",
end: "top 30%",
scrub: 1,
},
});
});
// ── Labels entrance ──
document.querySelectorAll(".section:not(.hero-section) .label").forEach((el) => {
gsap.set(el, { opacity: 0, y: reduced ? 0 : 15 });
gsap.to(el, {
opacity: 1,
y: 0,
duration: dur(0.5),
ease: "expo.out",
scrollTrigger: {
trigger: el.closest(".section"),
start: "top 80%",
toggleActions: "play none none reverse",
},
});
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Text Reveal on Scroll — stealthisdesign</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"gsap":"https://esm.sh/gsap@3.13.0","gsap/ScrollTrigger":"https://esm.sh/gsap@3.13.0/ScrollTrigger","gsap/SplitText":"https://esm.sh/gsap@3.13.0/SplitText","gsap/Flip":"https://esm.sh/gsap@3.13.0/Flip","gsap/ScrambleTextPlugin":"https://esm.sh/gsap@3.13.0/ScrambleTextPlugin","gsap/TextPlugin":"https://esm.sh/gsap@3.13.0/TextPlugin","gsap/all":"https://esm.sh/gsap@3.13.0/all","gsap/":"https://esm.sh/gsap@3.13.0/","lenis":"https://esm.sh/lenis@1.1.13/dist/lenis.mjs","three":"https://esm.sh/three@0.171.0","three/addons/":"https://esm.sh/three@0.171.0/examples/jsm/"}}</script>
<style>html.lenis,
html.lenis body {
height: auto;
}
.lenis:not(.lenis-autoToggle).lenis-stopped {
overflow: clip;
}
.lenis [data-lenis-prevent],
.lenis [data-lenis-prevent-wheel],
.lenis [data-lenis-prevent-touch] {
overscroll-behavior: contain;
}
.lenis.lenis-smooth iframe {
pointer-events: none;
}
.lenis.lenis-autoToggle {
transition-property: overflow;
transition-duration: 1ms;
transition-behavior: allow-discrete;
}</style>
</head>
<body>
<!-- Hero -->
<section class="section hero-section">
<div class="content">
<span class="label">Demo 03</span>
<h1 class="split-headline">Typography in Motion</h1>
<p class="split-sub">Scroll down to reveal text character by character, line by line.</p>
</div>
</section>
<!-- Character reveal -->
<section class="section">
<div class="content">
<span class="label">Character Reveal</span>
<h2 class="split-chars">Every letter carries weight.</h2>
<p class="split-lines">Characters cascade into view with rotational depth, creating a sense of dimensionality that flat fades can never achieve. The stagger timing is crucial — too fast feels frantic, too slow feels sluggish.</p>
</div>
</section>
<!-- Word reveal -->
<section class="section">
<div class="content">
<span class="label">Word Reveal</span>
<h2 class="split-words">Words emerge from silence into meaning.</h2>
<p class="split-lines">When words arrive as units, they carry more semantic weight. The reader processes clusters of meaning rather than individual glyphs, creating a rhythm that mirrors natural speech patterns.</p>
</div>
</section>
<!-- Line reveal -->
<section class="section">
<div class="content">
<span class="label">Line Reveal</span>
<h2 class="split-lines-heading">Lines unfold like<br>pages turning in<br>a silent film.</h2>
</div>
</section>
<!-- Mixed styles -->
<section class="section">
<div class="content">
<span class="label">Scrub Reveal</span>
<h2 class="scrub-chars">Scroll controls the narrative.</h2>
<p class="split-lines">This section ties character reveal directly to scroll progress — scrub back and forth to see each letter respond to your scroll position. No play/reverse, just pure scroll-linked choreography.</p>
</div>
</section>
<!-- Quote -->
<section class="section section-quote">
<div class="content">
<blockquote class="split-chars quote-text">"Good animation is invisible. Great animation is unforgettable."</blockquote>
</div>
</section>
<!-- Finale -->
<section class="section">
<div class="content">
<h2 class="split-chars">Fin.</h2>
<a href="/" class="back-link">Back to Gallery</a>
</div>
</section>
<script type="module" src="script.js"></script>
</body>
</html>Text Reveal on Scroll
Per-character and per-line text animations triggered on scroll with GSAP SplitText.
Source
- Repository:
libs-genclaude - Original demo id:
03-text-reveal
Notes
Per-character and per-line text animations triggered on scroll with GSAP SplitText.