17 KiB
Text Animation Reference
Table of Contents
- Setup: SplitText & Dependencies
- Technique 1: Split Converge (Left+Right Merge)
- Technique 2: Masked Line Curtain Reveal
- Technique 3: Character Cylinder Rotation
- Technique 4: Word-by-Word Scroll Lighting
- Technique 5: Scramble Text
- Technique 6: Skew + Elastic Bounce Entry
- Technique 7: Theatrical Enter + Auto Exit
- Technique 8: Offset Diagonal Layout
- Technique 9: Line Clip Wipe
- Technique 10: Scroll-Speed Reactive Marquee
- Technique 11: Variable Font Wave
- Technique 12: Bleed Typography
Setup: SplitText & Dependencies
<!-- GSAP SplitText (free in GSAP 3.12+) -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/SplitText.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
<script>
gsap.registerPlugin(SplitText, ScrollTrigger);
</script>
Universal Text Setup CSS
/* All text elements that animate need this */
.anim-text {
overflow: hidden; /* Contains line mask reveals */
line-height: 1.15;
}
/* Screen reader: preserve meaning even when SplitText fragments it */
.anim-text[aria-label] > * {
aria-hidden: true;
}
Technique 1: Split Converge (Left+Right Merge)
The signature effect: two halves of a title fly in from opposite sides, converge to form the complete title, hold, then diverge and disappear on scroll exit. Exactly what the user described.
.hero-title {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
overflow: visible; /* allow parts to fly from outside viewport */
}
.hero-title .word-left {
display: inline-block;
/* starts at far left */
}
.hero-title .word-right {
display: inline-block;
/* starts at far right */
}
function initSplitConverge(titleEl) {
// Preserve accessibility
const fullText = titleEl.textContent;
titleEl.setAttribute('aria-label', fullText);
const words = titleEl.querySelectorAll('.word');
const midpoint = Math.floor(words.length / 2);
const leftWords = Array.from(words).slice(0, midpoint);
const rightWords = Array.from(words).slice(midpoint);
const tl = gsap.timeline({
scrollTrigger: {
trigger: titleEl.closest('.scene'),
start: 'top top',
end: '+=250%',
pin: true,
scrub: 1.2,
}
});
// Phase 1 — ENTER (0% → 25%): Words converge from sides
tl.fromTo(leftWords,
{ x: '-120vw', opacity: 0 },
{ x: 0, opacity: 1, duration: 0.25, ease: 'power3.out', stagger: 0.03 },
0
)
.fromTo(rightWords,
{ x: '120vw', opacity: 0 },
{ x: 0, opacity: 1, duration: 0.25, ease: 'power3.out', stagger: -0.03 },
0
)
// Phase 2 — HOLD (25% → 70%): Nothing — words are readable, section pinned
// (empty duration keeps the scrub paused here)
.to({}, { duration: 0.45 }, 0.25)
// Phase 3 — EXIT (70% → 100%): Words diverge back out
.to(leftWords,
{ x: '-120vw', opacity: 0, duration: 0.28, ease: 'power3.in', stagger: 0.02 },
0.70
)
.to(rightWords,
{ x: '120vw', opacity: 0, duration: 0.28, ease: 'power3.in', stagger: -0.02 },
0.70
);
return tl;
}
HTML Template
<h1 class="hero-title anim-text" aria-label="Your Brand Name">
<span class="word word-left">Your</span>
<span class="word word-left">Brand</span>
<span class="word word-right">Name</span>
<span class="word word-right">Here</span>
</h1>
Technique 2: Masked Line Curtain Reveal
Lines slide upward from behind an invisible curtain. Each line is hidden in an overflow: hidden container and translates up into view.
.curtain-text .line-mask {
overflow: hidden;
line-height: 1.2;
/* The mask — content starts below and slides up into view */
}
.curtain-text .line-inner {
display: block;
/* Starts translated down below the mask */
transform: translateY(110%);
}
function initCurtainReveal(textEl) {
// SplitText splits into lines automatically
const split = new SplitText(textEl, {
type: 'lines',
linesClass: 'line-inner',
// Wraps each line in overflow:hidden container
lineThreshold: 0.1,
});
// Wrap each line in a mask container
split.lines.forEach(line => {
const mask = document.createElement('div');
mask.className = 'line-mask';
line.parentNode.insertBefore(mask, line);
mask.appendChild(line);
});
gsap.from(split.lines, {
y: '110%',
duration: 0.9,
ease: 'power4.out',
stagger: 0.12,
scrollTrigger: {
trigger: textEl,
start: 'top 80%',
}
});
}
Technique 3: Character Cylinder Rotation
Letters rotate in on a 3D cylinder axis — like a slot machine or odometer rolling into place. Premium, memorable.
.cylinder-text {
perspective: 800px;
}
.cylinder-text .char {
display: inline-block;
transform-origin: center center -60px; /* pivot point BEHIND the letter */
transform-style: preserve-3d;
}
function initCylinderRotation(titleEl) {
const split = new SplitText(titleEl, { type: 'chars' });
gsap.from(split.chars, {
rotateX: -90,
opacity: 0,
duration: 0.6,
ease: 'back.out(1.5)',
stagger: {
each: 0.04,
from: 'start'
},
scrollTrigger: {
trigger: titleEl,
start: 'top 75%',
}
});
}
Technique 4: Word-by-Word Scroll Lighting
Words appear to light up one at a time, driven by scroll position. Apple's signature prose technique.
.scroll-lit-text {
/* Start all words dim */
}
.scroll-lit-text .word {
display: inline-block;
color: rgba(255, 255, 255, 0.15); /* dim unlit state */
transition: color 0.1s ease;
}
.scroll-lit-text .word.lit {
color: rgba(255, 255, 255, 1.0); /* bright lit state */
}
function initWordScrollLighting(containerEl, textEl) {
const split = new SplitText(textEl, { type: 'words' });
const words = split.words;
const totalWords = words.length;
// Pin the section and light words as user scrolls
ScrollTrigger.create({
trigger: containerEl,
start: 'top top',
end: `+=${totalWords * 80}px`, // ~80px per word
pin: true,
scrub: 0.5,
onUpdate: (self) => {
const progress = self.progress;
const litCount = Math.round(progress * totalWords);
words.forEach((word, i) => {
word.classList.toggle('lit', i < litCount);
});
}
});
}
Technique 5: Scramble Text
Characters cycle through random values before resolving to real text. Feels digital, techy, premium.
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/TextPlugin.min.js"></script>
// Custom scramble implementation (no plugin needed)
function scrambleText(el, finalText, duration = 1.5) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%';
let startTime = null;
const originalText = finalText;
function step(timestamp) {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / (duration * 1000), 1);
let result = '';
for (let i = 0; i < originalText.length; i++) {
if (originalText[i] === ' ') {
result += ' ';
} else if (i / originalText.length < progress) {
// This character has resolved
result += originalText[i];
} else {
// Still scrambling
result += chars[Math.floor(Math.random() * chars.length)];
}
}
el.textContent = result;
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// Trigger on scroll
ScrollTrigger.create({
trigger: '.scramble-title',
start: 'top 80%',
once: true,
onEnter: () => {
scrambleText(
document.querySelector('.scramble-title'),
document.querySelector('.scramble-title').dataset.text,
1.8
);
}
});
Technique 6: Skew + Elastic Bounce Entry
Elements enter with a skew that corrects itself, combined with a slight overshoot. Feels physical and energetic.
function initSkewBounce(elements) {
gsap.from(elements, {
y: 80,
skewY: 7,
opacity: 0,
duration: 0.9,
ease: 'back.out(1.7)',
stagger: 0.1,
scrollTrigger: {
trigger: elements[0],
start: 'top 85%',
}
});
}
Technique 7: Theatrical Enter + Auto Exit
Element automatically animates in when entering the viewport AND animates out when leaving — zero JavaScript needed.
/* Enter animation */
@keyframes theatrical-enter {
from {
opacity: 0;
transform: translateY(60px);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0px);
}
}
/* Exit animation */
@keyframes theatrical-exit {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-60px);
}
}
.theatrical {
/* Enter when element comes into view */
animation: theatrical-enter linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
.theatrical-with-exit {
animation: theatrical-enter linear both, theatrical-exit linear both;
animation-timeline: view(), view();
animation-range: entry 0% entry 30%, exit 60% exit 100%;
}
Zero JavaScript required. Just add .theatrical or .theatrical-with-exit class.
Technique 8: Offset Diagonal Layout
Lines of a title start at offset positions (one top-left, one lower-right), then animate FROM their natural offset positions FROM opposite directions. Creates a staircase visual composition that feels dynamic even before animation.
.offset-title {
position: relative;
/* Don't center — let offset do the work */
}
.offset-title .line-1 {
/* Top-left */
display: block;
text-align: left;
padding-left: 5%;
font-size: clamp(48px, 8vw, 100px);
}
.offset-title .line-2 {
/* Lower-right — drops down and shifts right */
display: block;
text-align: right;
padding-right: 5%;
margin-top: 0.4em;
font-size: clamp(48px, 8vw, 100px);
}
function initOffsetDiagonal(titleEl) {
const line1 = titleEl.querySelector('.line-1');
const line2 = titleEl.querySelector('.line-2');
gsap.from(line1, {
x: '-15vw',
opacity: 0,
duration: 1.0,
ease: 'power4.out',
scrollTrigger: { trigger: titleEl, start: 'top 75%' }
});
gsap.from(line2, {
x: '15vw',
opacity: 0,
duration: 1.0,
ease: 'power4.out',
delay: 0.15,
scrollTrigger: { trigger: titleEl, start: 'top 75%' }
});
}
Technique 9: Line Clip Wipe
Each line of text reveals from left to right, like a typewriter but with a clean clip-path sweep.
function initLineClipWipe(textEl) {
const split = new SplitText(textEl, { type: 'lines' });
split.lines.forEach((line, i) => {
gsap.fromTo(line,
{ clipPath: 'inset(0 100% 0 0)' },
{
clipPath: 'inset(0 0% 0 0)',
duration: 0.8,
ease: 'power3.out',
delay: i * 0.12, // stagger between lines
scrollTrigger: {
trigger: textEl,
start: 'top 80%',
}
}
);
});
}
Technique 10: Scroll-Speed Reactive Marquee
Infinite scrolling text. Speed scales with scroll velocity — fast scroll = fast marquee. Slow scroll = slow/paused.
.marquee-wrapper {
overflow: hidden;
white-space: nowrap;
}
.marquee-track {
display: inline-flex;
gap: 4rem;
/* Two copies side by side for seamless loop */
}
.marquee-track .marquee-item {
display: inline-block;
font-size: clamp(2rem, 5vw, 5rem);
font-weight: 700;
letter-spacing: -0.02em;
}
function initReactiveMarquee(wrapperEl) {
const track = wrapperEl.querySelector('.marquee-track');
let currentX = 0;
let velocity = 0;
let baseSpeed = 0.8; // px per frame base speed
let lastScrollY = window.scrollY;
let lastTime = performance.now();
// Track scroll velocity
window.addEventListener('scroll', () => {
const now = performance.now();
const dt = now - lastTime;
const dy = window.scrollY - lastScrollY;
velocity = Math.abs(dy / dt) * 30; // scale to marquee speed
lastScrollY = window.scrollY;
lastTime = now;
}, { passive: true });
function animate() {
velocity = Math.max(0, velocity - 0.3); // decay
const speed = baseSpeed + velocity;
currentX -= speed;
// Reset when first copy exits viewport
const trackWidth = track.children[0].offsetWidth * track.children.length / 2;
if (Math.abs(currentX) >= trackWidth) {
currentX += trackWidth;
}
track.style.transform = `translateX(${currentX}px)`;
requestAnimationFrame(animate);
}
animate();
}
Technique 11: Variable Font Wave
If the font supports variable axes (weight, width), animate them per-character for a wave/ripple effect.
function initVariableFontWave(titleEl) {
const split = new SplitText(titleEl, { type: 'chars' });
// Wave through characters using weight axis
gsap.to(split.chars, {
fontVariationSettings: '"wght" 800',
duration: 0.4,
ease: 'power2.inOut',
stagger: {
each: 0.06,
yoyo: true,
repeat: -1, // infinite loop
}
});
}
Note: Requires a variable font. Free options: Inter Variable, Fraunces, Recursive. Load from Google Fonts with ?display=swap&axes=wght.
Technique 12: Bleed Typography
Oversized headline that intentionally exceeds section boundaries. Creates drama, depth, and visual tension.
.bleed-title {
font-size: clamp(80px, 18vw, 220px);
font-weight: 900;
line-height: 0.9;
letter-spacing: -0.04em;
/* Allow bleeding outside section */
position: relative;
z-index: 10;
pointer-events: none;
/* Negative margins to bleed out */
margin-left: -0.05em;
margin-right: -0.05em;
/* Optionally: half above, half below section boundary */
transform: translateY(30%);
}
/* Parent section allows overflow */
.bleed-section {
overflow: visible;
position: relative;
z-index: 2;
}
/* Next section needs to be higher to "trap" the bleed */
.bleed-section + .next-section {
position: relative;
z-index: 3;
}
// Parallax on the bleed title — moves at slightly different rate
// to emphasize that it belongs to a different depth than content
gsap.to('.bleed-title', {
y: '-12%',
ease: 'none',
scrollTrigger: {
trigger: '.bleed-section',
start: 'top bottom',
end: 'bottom top',
scrub: true,
}
});
Technique 13: Ghost Outlined Background Text
Massive atmospheric text sitting BEHIND the main product using only a thin stroke with transparent fill. Supports the scene without competing with the content.
.ghost-bg-text {
color: transparent;
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.15); /* light sites */
/* dark sites: -webkit-text-stroke: 1px rgba(255, 106, 26, 0.18); */
font-size: clamp(5rem, 15vw, 18rem);
font-weight: 900;
line-height: 0.85;
letter-spacing: -0.04em;
white-space: nowrap;
z-index: 2; /* must be lower than the hero product (depth-3 = z-index 3+) */
pointer-events: none;
user-select: none;
}
// Entrance: lines slide up from a masked overflow:hidden parent
function initGhostTextEntrance(lines) {
gsap.set(lines, { y: '110%' });
gsap.to(lines, {
y: '0%',
stagger: 0.1,
duration: 1.1,
ease: 'power4.out',
delay: 0.2,
});
}
// Exit: lines drift apart as hero scrolls out
function addGhostTextExit(scrubTimeline, line1, line2) {
scrubTimeline
.to(line1, { x: '-12vw', opacity: 0.06, duration: 0.3 }, 0)
.to(line2, { x: '12vw', opacity: 0.06, duration: 0.3 }, 0)
.to(line1, { x: '-40vw', opacity: 0, duration: 0.25 }, 0.4)
.to(line2, { x: '40vw', opacity: 0, duration: 0.25 }, 0.4);
}
Stroke opacity guide:
0.08–0.12→ barely-there atmosphere0.15–0.22→ readable on inspection, still subtle0.25–0.35→ prominently visible — only if it IS the visual focus
Rules:
- Always
aria-hidden="true"— never the real heading - A real
<h1>must exist elsewhere for SEO/screen readers - Only works on dark backgrounds — thin strokes vanish on light ones
- Maximum 2 lines — 3+ becomes noise
- Best with ultra-heavy weights (800–900) and tight letter-spacing
Combining Techniques
The most premium results come from layering multiple text techniques in the same section:
// Example: Full hero text sequence
function initHeroTextSequence() {
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.hero-scene',
start: 'top top',
end: '+=300%',
pin: true,
scrub: 1,
}
});
// 1. Bleed title already visible via CSS
// 2. Subtitle curtain reveal
tl.from('.hero-sub .line-inner', {
y: '110%', duration: 0.2, stagger: 0.05
}, 0)
// 3. CTA skew bounce
.from('.hero-cta', {
y: 40, skewY: 5, opacity: 0, duration: 0.15, ease: 'back.out'
}, 0.15)
// 4. On scroll-through: title exits via split converge reverse
.to('.hero-title .word-left', {
x: '-80vw', opacity: 0, duration: 0.25, stagger: 0.03
}, 0.7)
.to('.hero-title .word-right', {
x: '80vw', opacity: 0, duration: 0.25, stagger: -0.03
}, 0.7);
}