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.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--ink,--aubergine,--bone,--signal. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the
lenis.on("scroll", ScrollTrigger.update)wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.
Adapting this to React
Everything above describes a standalone document: three sections wired up once, on a page that only ever loads them a single time. React withdraws that guarantee — under React 19 with StrictMode, this effect mounts, unmounts, and mounts again before anything reaches the screen. Run this component's setup twice without a matching teardown and you get two Lenis instances both driven by the same gsap.ticker, fighting over the same wheel event; a second ScrollTrigger stacked on section two's headline that fires tl.restart() on a tl the first instance already owns, so the reveal on scroll-enter doubles up or fights the scrub happening in section three; and three more SplitText calls layered onto headlines whose .char spans are already split, so the second pass nests .char inside .char and the per-line index math (charIndexInLine) that makes all lines reveal in parallel now runs against the wrong nodes. None of this shows up in a production build — only in development, where the double-invoke happens. Treat the cleanup as part of the effect.
*(1) The entry point* — The script's entire body — creating Lenis, wiring it to gsap.ticker, and waiting on document.fonts.ready before touching any headline — sits inside a document.addEventListener("DOMContentLoaded", …) listener. That event has already fired by the time a React component mounts (React needed a ready DOM to mount into), so this listener is simply dead: no error, no smooth scroll, no reveal. Delete the listener and move its body into a useEffect with an empty dependency array.
*(2) Element lookups* — document.querySelectorAll(".animated-header") finds all three <h1>s by class, and which of the three modes each one gets is decided purely by reading data-animate-on-scroll / data-scrub off that same element inside the loop — none of that per-headline logic changes. What has to change is the query itself: give the component's outermost wrapper a rootRef and query rootRef.current.querySelectorAll(".animated-header") instead of document. During the StrictMode remount, two copies of the three sections exist for an instant, and an unscoped query would just as happily attach a ScrollTrigger to the copy that is on its way out.
*(3) Cleanup* — this component accumulates five kinds of state that each need their own teardown: a Lenis instance, the gsap.ticker subscription driving it, up to three GSAP timelines plus two ScrollTriggers (sections two and three only — section one has neither), and three SplitText splits, one per headline. A gsap.context scoped to the root ref covers the timelines and triggers automatically; Lenis, the ticker, and the three splits are not GSAP objects, so each needs an explicit line in the cleanup.
The document.fonts.ready wait is where the async concern and the gsap.context concern collide, and the way to avoid the self/ctx ambiguity entirely is to let the promise's .then() be the thing that opens the context, instead of opening it up front and reaching into it later from a deferred callback:
useEffect(() => {
let cancelled = false;
let ctx;
const lenis = new Lenis({ autoRaf: false });
lenis.on("scroll", ScrollTrigger.update);
const driveLenis = (time) => lenis.raf(time * 1000);
gsap.ticker.add(driveLenis);
gsap.ticker.lagSmoothing(0);
document.fonts.ready.then(() => {
if (cancelled) return; // unmounted before fonts settled — nothing left to split
ctx = gsap.context(() => {
const splits = [];
rootRef.current.querySelectorAll(".animated-header").forEach((el) => {
const split = SplitText.create(el, { type: "lines,words,chars", autoSplit: true });
splits.push(split);
initAnimatedHeader(el, split, {
animateOnScroll: el.hasAttribute("data-animate-on-scroll"),
scrub: el.hasAttribute("data-scrub"),
});
});
return () => splits.forEach((split) => split.revert());
}, rootRef);
});
return () => {
cancelled = true;
gsap.ticker.remove(driveLenis);
lenis.destroy();
ctx?.revert();
};
}, []);
Because the context's factory now runs later — inside the .then(), once fonts are actually ready — everything it creates (the three headlines' timelines, the two ScrollTriggers) is still created during that call's own synchronous pass, so the context tracks all of it without ever writing self.add or ctx.add: there is no gap between "the context exists" and "the content is created" for a deferred callback to fall into. The one thing the context cannot infer on its own is the three SplitText splits, since reverting a split rewrites the DOM rather than killing a tween; returning () => splits.forEach(...) from the factory registers that cleanup as part of the same teardown, so ctx.revert() runs it too. Skip that return and the next mount's three SplitText.create calls wrap .char spans that are already .char spans — the doubled-nesting failure described above.
gsap.ticker.add(driveLenis) is neither a tween nor a trigger, so ctx.revert() never touches it. This is what drives lenis.raf on every tick, since autoRaf stays off here, and it has to be removed by that same function reference or it keeps calling .raf() on a Lenis instance that destroy() has already torn down. This component owns its Lenis instance outright, so creating and destroying it here is correct; if this section is later composed into a page that already runs Lenis, delete this whole instance/ticker/lenis.on block in favor of the existing one, per the note above about a single shared instance. lenis.destroy() already tears down the "scroll" listener along with the raf loop, so that line needs no separate unsubscribe.
gsap.registerPlugin(SplitText, ScrollTrigger) stays exactly where it already is, at module scope outside the component — it only has to run once for the life of the page, not once per mount.