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 & 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 & 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.colisdisplay:flex, second istext-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; }withh1/spanin#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 becomescolumn-reversewithgap:4emand 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
- Read the attribute API:
animateOnScroll = container.dataset.copyScroll !== "false"(defaults to true; no element in this page sets it to false, but support it).delay = parseFloat(container.dataset.copyDelay || "0")— only the heroh1setsdata-copy-delay="0.5".- Resolve targets: if the container has the
data-copy-wrapperattribute, split each direct child (Array.from(container.children)— the story paragraphs and the footer link h1s); otherwise split the container itself. - 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.
- Text-indent transfer (crucial for
.about/.philosophyh1s): readgetComputedStyle(element).textIndent; if it's set and not"0px", apply that value aspaddingLefton the first split line only (split.lines[0].style.paddingLeft = textIndent) and set the element's owntext-indentto0. Without this, every wrapped line would inherit the 25% indent. - Collect all
split.linesfrom all targets into onelinesarray per container. - Initial state:
gsap.set(lines, { y: "100%" })(each line fully below its mask; matches the.lineCSS fallback). - 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 the0.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.outgives a fast launch with a long soft landing over the 1s duration. - For
data-copy-wrappercontainers 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:
- 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).
- 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
.lineCSS class (translateY 100%) plus the SplitText masks guarantee zero flash even beforedocument.fonts.readyresolves. - Lenis provides the smooth, damped scroll feel; ScrollTrigger is updated from Lenis's scroll event and
gsap.tickerdriveslenis.raf(time * 1000)withlagSmoothing(0). - Sections use
100svhso mobile browser chrome doesn't clip them; the.about-imgand.storysections areheight: max-contentexceptions. - 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.