All components

Line-by-Line Text Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

A scroll-driven line-by-line text reveal. GSAP SplitText breaks each heading and paragraph into masked lines, gsap.set pushes them to y:100%, and a ScrollTrigger fired once at 'top 75%' tweens them back to y:0% (duration 1, stagger 0.1, power4.out); the hero headline runs on load with a 0.5s delay. Lenis provides smooth scrolling.

How it's built

Categorytext
Techgsap, lenis
GSAP pluginsSplitText, ScrollTrigger
Complexitypage
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

text-reveal split-text line-mask scroll stagger lenis editorial minimal

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 (Masked Line-by-Line Scroll Reveal)

Goal

Build a minimal, editorial one-page site for a fictional design studio ("Greyloom") where every headline, label and paragraph reveals itself line by line: GSAP SplitText breaks each text block into lines, each line is wrapped in an overflow-clipping mask, pushed down to y: 100%, and slid up to y: 0% with a power4.out ease and a 0.1s stagger — triggered once per block when it scrolls to 75% of the viewport (the hero headline plays on load with a 0.5s delay). Scrolling is smoothed with Lenis. The reveal system is generic and attribute-driven (data-copy, data-copy-wrapper, data-copy-delay, data-copy-scroll).

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the SplitText and ScrollTrigger plugins, plus lenis for smooth scrolling:

import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

gsap.registerPlugin(SplitText, ScrollTrigger);

Everything runs inside a DOMContentLoaded listener. Wire Lenis the standard way:

const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);

Layout / HTML

One page, six blocks in this order. The attributes data-copy, data-copy-wrapper="true" and data-copy-delay are the animation API — keep them exact.

<nav>
  <div class="col">
    <div class="sub-col"><span>Greyloom</span></div>
    <div class="sub-col">
      <span>Home</span><span>Projects</span><span>About</span><span>Lab</span>
    </div>
  </div>
  <div class="col"><span>Let's talk</span></div>
</nav>

<section class="hero">
  <div class="hero-img"><img src="(hero image)" alt="" /></div>
  <div class="header">
    <h1 data-copy data-copy-delay="0.5">
      We craft identities and experiences for the bold.
    </h1>
  </div>
</section>

<section class="about">
  <span data-copy>Design &amp; Strategy for the Vision-Driven</span>
  <div class="header">
    <h1 data-copy>
      We partner with founders, innovators, and change-makers to shape
      brands that resonate. From first lines of code to global launches,
      we bring focus, elegance, and intent to every stage.
    </h1>
  </div>
</section>

<section class="about-img">
  <img src="(about image)" alt="" />
</section>

<section class="story">
  <div class="col">
    <h1 data-copy>The Story Behind <br /> Our Stillness</h1>
  </div>
  <div class="col">
    <div data-copy data-copy-wrapper="true">
      <p>Greyloom was born from a simple idea: that creativity, when wielded
      with intention, can quietly reshape the world. In an era of
      overstimulation and fleeting trends, we chose a different path. One of
      clarity, restraint, and long-form vision.</p>
      <p>We began as a small collective of designers, developers, and
      strategists who shared an obsession with thoughtful execution. No
      shortcuts, no templates. Just the hard, honest work of listening deeply,
      thinking critically, and building beautifully. Over time, our work began
      to attract the kind of clients we had always hoped for. Visionary
      founders, principled organizations, and global teams with sharp ideas
      and quiet confidence.</p>
      <p>We don't chase virality. We don't trade in noise. We build for the
      long haul: timeless identities, seamless digital experiences, and
      strategies that evolve with clarity and purpose. Greyloom exists for
      those who believe that the most enduring ideas don't demand attention.
      They earn it.</p>
    </div>
  </div>
</section>

<section class="philosophy">
  <span data-copy>The Thought Beneath</span>
  <div class="header">
    <h1 data-copy>
      We believe in the power of quiet conviction. In work that speaks softly
      but lingers long. In design as a tool for clarity, not decoration. We
      believe that the best ideas don't demand attention. Our philosophy is
      simple. Create with purpose.
    </h1>
  </div>
</section>

<footer>
  <div class="col">
    <div class="sub-col"><span>Terms &amp; Conditions</span></div>
    <div class="sub-col">
      <div data-copy data-copy-wrapper="true">
        <h1>Twitter</h1><h1>LinkedIn</h1><h1>Instagram</h1>
        <h1>Awwwards</h1><h1>Email</h1>
      </div>
    </div>
  </div>
  <div class="col"><span>Copyright Greyloom 2025</span></div>
</footer>

Styling

Monochrome editorial palette: black #000, mid grey #909090, near-black #202020, white #fff on a default white page.

Typography: the design intends a neutral grotesque sans for headings/body (a premium font in the demo — use a clean grotesque or plain sans-serif) and a small monospace-style face for the uppercase labels.

  • Global reset * { margin:0; padding:0; box-sizing:border-box; }; body { font-family: sans-serif; } (grotesque stack).
  • h1 { font-size:3.5rem; font-weight:500; letter-spacing:-0.05rem; line-height:1; }
  • p { font-size:1.125rem; font-weight:500; line-height:1.25; margin-bottom:1em; }
  • span { color:#000; display:block; text-transform:uppercase; font-size:0.75rem; font-weight:500; } — mono-style label face.
  • img { width:100%; height:100%; object-fit:cover; }
  • nav { position:absolute; top:0; left:0; width:100%; display:flex; padding:1.5em 2em; } — first .col is display:flex, second is text-align:right. nav span { color:#909090; mix-blend-mode:difference; } so the nav stays legible over the hero image.
  • section { position:relative; width:100vw; height:100svh; padding:2em; }
  • .col, .sub-col { flex:1; }
  • .hero, .about-img { display:flex; justify-content:center; align-items:center; }
  • .hero-img { position:absolute; width:100%; height:100%; overflow:hidden; z-index:-1; } — full-bleed background image behind the headline.
  • .hero .header { width:50%; text-align:center; } and .hero .header h1 { color:#909090; }
  • .about, .philosophy { display:flex; flex-direction:column; justify-content:space-between; } — small label at the top, big headline at the bottom.
  • .philosophy { background-color:#202020; } with h1/span in #fff (the one dark section).
  • .about h1, .philosophy h1 { text-indent:25%; } — this first-line indent is load-bearing: the JS transfers it to the first split line (see below).
  • .about-img { height:max-content; padding:8em 2em; } and .about-img img { width:20%; aspect-ratio:4/5; } — a small centered portrait between text sections.
  • .story { height:max-content; display:flex; gap:1em; margin-bottom:8em; }
  • footer { display:flex; justify-content:space-between; align-items:flex-end; gap:1em; padding:6em 2em 1.5em 2em; }; footer .col { display:flex; justify-content:flex-end; }; footer .sub-col { display:flex; align-items:flex-end; }
  • .line { transform:translateY(100%); will-change:transform; } — SplitText lines get this class, so text is hidden inside its mask before JS runs (no flash of visible text).
  • @media (max-width:900px): h1 { font-size:2rem; }; hide the nav's second sub-col (the menu links); .hero .header { width:95%; }; .about-img img { width:100%; }; .story { flex-direction:column; }; footer's first col becomes column-reverse with gap:4em and its second col is hidden.

GSAP effect (the important part — be exhaustive)

Init gate

Wait for document.fonts.ready, then run initCopy(container) for every [data-copy] element in DOM order. (Splitting before fonts load would compute wrong line breaks.)

initCopy(container) — the reveal system
  1. Read the attribute API:
  2. animateOnScroll = container.dataset.copyScroll !== "false" (defaults to true; no element in this page sets it to false, but support it).
  3. delay = parseFloat(container.dataset.copyDelay || "0") — only the hero h1 sets data-copy-delay="0.5".
  4. Resolve targets: if the container has the data-copy-wrapper attribute, split each direct child (Array.from(container.children) — the story paragraphs and the footer link h1s); otherwise split the container itself.
  5. Split each target element with SplitText:

``js const split = SplitText.create(element, { type: "lines", mask: "lines", // wraps every line in an overflow-clipping mask element linesClass: "line++", // each line gets class "line" plus an incremented "line1", "line2", … lineThreshold: 0.1, }); ` mask: "lines"` (GSAP 3.13+) is what creates the clipping wrappers — the reveal must look like lines rising out of invisible slots, not fading in.

  1. Text-indent transfer (crucial for .about/.philosophy h1s): read getComputedStyle(element).textIndent; if it's set and not "0px", apply that value as paddingLeft on the first split line only (split.lines[0].style.paddingLeft = textIndent) and set the element's own text-indent to 0. Without this, every wrapped line would inherit the 25% indent.
  2. Collect all split.lines from all targets into one lines array per container.
  3. Initial state: gsap.set(lines, { y: "100%" }) (each line fully below its mask; matches the .line CSS fallback).
  4. The tween (shared props):

``js { y: "0%", duration: 1, stagger: 0.1, ease: "power4.out", delay: delay, } ``

  • If animateOnScroll (default): gsap.to(lines, { ...props, scrollTrigger: { trigger: container, start: "top 75%", once: true } }) — fires once, never reverses, no scrub, no pin.
  • Else: plain gsap.to(lines, props) on load.
Resulting choreography
  • Hero headline: its ScrollTrigger (top 75%) is already past at load, so it fires immediately — but the 0.5s delay makes the two centered grey lines rise ~half a second after page load.
  • Each subsequent block (about label, about headline, story title, three story paragraphs, philosophy label, philosophy headline, footer links) reveals independently when it crosses 75% of the viewport height.
  • Within a block, lines cascade top-to-bottom 0.1s apart; power4.out gives a fast launch with a long soft landing over the 1s duration.
  • For data-copy-wrapper containers the lines of all children join one array, so the stagger runs continuously across paragraph boundaries (the three story paragraphs read as a single cascading column; the five footer h1s rise one after another).

There is no timeline, no scrub, no pin — just one gsap.to per [data-copy] container.

Assets / images

2 images, described by role:

  1. Hero background — full-bleed landscape image behind the centered grey headline: a glossy black abstract sculptural/fluid form with soft highlights on a bright white background (monochrome, high-key).
  2. About portrait — vertical 4:5 image shown small (20% width) and centered in its own section: a dark, moody close-up of a glossy black draped/fluid form with subtle grey highlights on a near-black background (monochrome, low-key).

No logos or brand marks — the nav/footer are plain text labels for the fictional studio "Greyloom".

Behavior notes

  • Every reveal uses once: true — scrolling back up never re-hides or replays text.
  • Text must be invisible before its animation: the .line CSS class (translateY 100%) plus the SplitText masks guarantee zero flash even before document.fonts.ready resolves.
  • Lenis provides the smooth, damped scroll feel; ScrollTrigger is updated from Lenis's scroll event and gsap.ticker drives lenis.raf(time * 1000) with lagSmoothing(0).
  • Sections use 100svh so mobile browser chrome doesn't clip them; the .about-img and .story sections are height: max-content exceptions.
  • Responsive at ≤900px (smaller type, stacked story, simplified nav/footer) — the reveal effect itself runs unchanged on mobile.
  • No CustomEase, no Three.js, no canvas, no reduced-motion branch.