Text Reveal Animation (per-line char slide-in with skew, three trigger modes)
Goal
Build a demo page of three full-viewport sections, each centered on one giant uppercase headline, where every character of the headline slides in from the right with a skew and fades into place. Each <h1> is split into lines / words / chars with GSAP SplitText, and the chars animate x:100 → 0, skewX:20 → 0, opacity:0 → 1 on a power3.out ease. The signature detail: the per-character stagger is keyed to each char's index within its own line, so on a multi-line headline all lines reveal in parallel (all first-chars fire together, all second-chars 0.05s later, etc.) rather than one long left-to-right sweep across the whole block. The three sections demonstrate three trigger modes: (1) plays once on page load, (2) replays every time it scrolls into view (and resets when scrolled back above), (3) scrubs forward/back tied to scroll position. Smooth scroll via Lenis.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins SplitText and ScrollTrigger, and lenis (npm) for smooth scroll. No framework, no Three.js, no CustomEase. Imports:
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
Register once at top level: gsap.registerPlugin(SplitText, ScrollTrigger);. Note SplitText is a GSAP plugin (bundled with modern gsap npm).
Layout / HTML
Three <section>s, each with a single centered headline. The trigger mode is chosen by a data-attribute on the <h1>:
<section class="one">
<h1 class="animated-header">Lorem ipsum dolor sit</h1>
</section>
<section class="two">
<h1 class="animated-header" data-animate-on-scroll>Lorem ipsum dolor sit, amet consectetur adipisicing</h1>
</section>
<section class="three">
<h1 class="animated-header" data-scrub>Lorem ipsum dolor sit amet</h1>
</section>
- Every headline carries the class
animated-header(the JS selects all of them). - Section one's headline has no data-attribute → plays once on load.
- Section two's headline has
data-animate-on-scroll→ replays on scroll-enter. - Section three's headline has
data-scrub→ scroll-scrubbed. - The middle headline is intentionally the longest text (wraps to multiple lines at
width:65%) so the parallel-per-line reveal is visible. Use neutral placeholder copy (e.g. lorem-style phrases); no brand names.
Styling
Import the font (a heavy condensed sans, all weights + italics):
@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap");
Palette (CSS variables on :root) — three high-contrast dark-bg / neon-fg pairs:
--base-100: #23002b(deep purple)--base-200: #ff94c2(pink)--base-300: #002529(deep teal)--base-400: #94c6ff(light blue)--base-500: #291900(deep brown)--base-600: #c2ff46(acid green)
Reset: * { margin:0; padding:0; box-sizing:border-box; }
Typography (the display headline):
h1 {
text-transform: uppercase;
font-family: "Barlow Condensed", sans-serif;
font-weight: 900;
font-size: clamp(3rem, 10vw, 15rem);
letter-spacing: -2%;
line-height: 0.75; /* tight leading — lines pack close */
}
Sections (each fills the viewport, centers its headline):
section {
position: relative;
width: 100%;
height: 100svh;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
overflow: hidden; /* clips the x:100 offset chars while they slide in */
}
section h1 { width: 65%; } /* forces long headlines to wrap to multiple lines */
.one { background-color: var(--base-100); color: var(--base-200); }
.two { background-color: var(--base-300); color: var(--base-400); }
.three { background-color: var(--base-500); color: var(--base-600); }
SplitText element display rules (these class names must match the SplitText config below):
.line { display: block; }
.word { display: inline-block; }
.char {
display: inline-block;
will-change: transform, opacity;
}
overflow:hidden on the section plus display:inline-block chars are what let each char translate horizontally without breaking layout or showing outside the panel.
GSAP effect (exhaustive — this is the core)
Smooth-scroll wiring (Lenis driven by the GSAP ticker)
Inside a DOMContentLoaded listener:
const lenis = new Lenis({ autoRaf: false });
lenis.on("scroll", ScrollTrigger.update); // keep ScrollTrigger synced to Lenis
gsap.ticker.add((time) => { lenis.raf(time * 1000); }); // Lenis rAF driven by GSAP ticker (ms)
gsap.ticker.lagSmoothing(0); // disable lag smoothing so scrub stays glued
autoRaf:false — Lenis does not run its own rAF loop; the GSAP ticker drives it.
Wait for fonts, then init each headline
document.fonts.ready.then(() => {
document.querySelectorAll(".animated-header").forEach((el) => {
initAnimatedHeader(el, {
animateOnScroll: el.hasAttribute("data-animate-on-scroll"),
scrub: el.hasAttribute("data-scrub"),
});
});
});
Waiting on document.fonts.ready is important — SplitText must measure line breaks against the final loaded font, or char positions will be wrong.
initAnimatedHeader(el, options) — signature & defaults
function initAnimatedHeader(el, {
animateOnScroll = false,
scrub = false,
delay = 0,
stagger = 0.05,
duration = 0.65,
} = {}) { … }
Step 1 — Split the headline
const split = SplitText.create(el, {
type: "lines,words,chars",
linesClass: "line",
wordsClass: "word",
charsClass: "char",
autoSplit: true,
});
const { chars, lines } = split;
type:"lines,words,chars" produces nested .line > .word > .char wrappers; autoSplit:true re-splits automatically if the font/layout changes so line grouping stays correct.
Step 2 — Set the initial (hidden) state of every char
gsap.set(chars, { x: 100, opacity: 0, skewX: 20 });
Each char starts 100px to the right, fully transparent, and skewed 20°.
Step 3 — Build per-line char metadata (the signature detail)
For each line, find only the chars that belong to that line and record each char's index within its own line:
const charMeta = lines.flatMap((line) => {
const lineChars = chars.filter((c) => line.contains(c));
return lineChars.map((char, charIndexInLine) => ({ char, charIndexInLine }));
});
So the first char of *every* line has charIndexInLine = 0, the second char of every line has 1, etc. This is what makes all lines animate in parallel instead of sequentially.
Step 4 — The animate(tl) builder
Add one tween per char onto the timeline, positioning each at an absolute time equal to its in-line index × stagger:
const animate = (tl) => {
charMeta.forEach(({ char, charIndexInLine }) => {
tl.to(
char,
{
x: 0,
opacity: 1,
skewX: 0,
ease: "power3.out",
duration, // 0.65
},
charIndexInLine * stagger, // absolute position on the timeline (index * 0.05s)
);
});
return tl;
};
Critical: the third argument is a number, an absolute timeline position (not "+="). Char N of *any* line is placed at N * 0.05s. Result: column 0 of all lines fires at t=0, column 1 at t=0.05, column 2 at t=0.10 … each char taking 0.65s with power3.out. Total timeline length = (maxCharsInLongestLine − 1) * 0.05 + 0.65.
Step 5 — Wire the trigger, three modes:
Mode A — default / on load (section one, no data-attr):
const tl = gsap.timeline({ delay }); // delay = 0
animate(tl);
An un-paused timeline → plays immediately (once fonts are ready). No ScrollTrigger.
Mode B — animateOnScroll (section two, data-animate-on-scroll):
const tl = gsap.timeline({ paused: true, delay });
animate(tl);
ScrollTrigger.create({
trigger: el,
start: "top 100%", // when the headline's top hits the viewport bottom
onEnter: () => tl.restart(), // (re)play from 0 each time it enters
onLeaveBack: () => tl.pause(0), // scrolling back up above it → reset to hidden frame
});
return;
Replays on every downward entry; resets to the start (hidden) frame when you scroll back above it.
Mode C — scrub (section three, data-scrub):
const tl = gsap.timeline({ paused: true });
animate(tl);
ScrollTrigger.create({
trigger: el,
start: "top 90%", // begins as the headline nears the bottom of the viewport
end: "top 45%", // fully revealed a bit past center
scrub: true, // timeline progress glued to scroll position (reversible)
animation: tl,
});
return;
The reveal is scrubbed: scrolling down plays it forward, scrolling up rewinds it. start/end map the headline's top from 90% → 45% of the viewport height onto timeline progress 0 → 1.
Assets / images
None. This component is pure type — no images, SVGs, canvas, or video.
Behavior notes
- Desktop and mobile both work; sizes are all
clamp()/vw/svhbased. No min-width gate. - The animation is gated on
document.fonts.ready, so nothing reveals until the web font has loaded (prevents mis-measured line splits). autoSplit:truemeans SplitText re-runs on relevant layout changes; the per-line grouping is recomputed accordingly. (Note: thecharMeta/timeline is built once at init in the original — it does not rebuild the timeline on resize.)- No
prefers-reduced-motionbranch in the original. - All three timelines are one-shot except Mode B, which re-fires on each scroll-enter; Mode C is fully reversible via scrub. No infinite loops.
- Section
overflow:hiddenis load-bearing: it clips thex:100starting offset so chars don't spill past the panel edge before they settle.