All components

Text Reveal Animation

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Char-level text reveal built on GSAP SplitText: each character slides in from x:100 with a 20-degree skew and fades to place (power3.out, 0.65s), staggered 0.05s by its index within its own line so all lines animate in parallel. Three trigger modes across full-height sections: on page load, on scroll enter (replays entering, resets when scrolled back above), and scrubbed between viewport positions, all under Lenis smooth scrolling.

How it's built

Categorytext
Techgsap, lenis
GSAP pluginsScrollTrigger, SplitText
Complexitysection
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

text reveal split-text chars stagger skew scroll scrub lenis

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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/svh based. 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:true means SplitText re-runs on relevant layout changes; the per-line grouping is recomputed accordingly. (Note: the charMeta/timeline is built once at init in the original — it does not rebuild the timeline on resize.)
  • No prefers-reduced-motion branch 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:hidden is load-bearing: it clips the x:100 starting offset so chars don't spill past the panel edge before they settle.