Scroll Letter Stagger — Free GSAP Split Text Snippet
Scroll Letter Stagger · Scroll · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<section class="sls-top"><p>Scroll ↓</p></section>
<section class="sls-stage" id="slsStage">
<h2 class="sls-line" id="slsLine1" data-text="Letters that"></h2>
<h2 class="sls-line sls-grad" id="slsLine2" data-text="ride the scroll"></h2>
<div class="sls-rule" id="slsRule"></div>
</section>
<section class="sls-bottom"><p>Scroll up — every letter retreats in reverse order.</p></section>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#07080d;color:#fff}
.sls-top,.sls-bottom{min-height:70vh;display:flex;justify-content:center;align-items:center;color:#8a90a8;font-size:15px;letter-spacing:.1em;text-transform:uppercase}
.sls-stage{height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;overflow:hidden;background:radial-gradient(75% 65% at 50% 45%,#111631,#07080d);padding:0 16px}
.sls-line{font-size:clamp(36px,8vw,84px);font-weight:800;letter-spacing:-.02em;line-height:1.08;white-space:nowrap;overflow:hidden;padding-bottom:.08em}
.sls-line .ch{display:inline-block;will-change:transform,opacity}
.sls-grad .ch{background:linear-gradient(120deg,#a5b4fc,#22d3ee);-webkit-background-clip:text;background-clip:text;color:transparent}
.sls-rule{width:0;max-width:min(420px,80vw);height:3px;border-radius:99px;background:linear-gradient(90deg,#818cf8,#22d3ee);margin-top:26px}gsap.registerPlugin(ScrollTrigger);
// Split each headline into inline-block character spans.
function split(el) {
var text = el.getAttribute('data-text');
var chars = [];
for (var i = 0; i < text.length; i++) {
var span = document.createElement('span');
span.className = 'ch';
span.textContent = text[i] === ' ' ? '\u00A0' : text[i];
el.appendChild(span);
chars.push(span);
}
return chars;
}
var chars1 = split(document.getElementById('slsLine1'));
var chars2 = split(document.getElementById('slsLine2'));
var tl = gsap.timeline({
scrollTrigger: {
trigger: '#slsStage',
start: 'top top',
end: '+=170%',
scrub: 0.4,
pin: true
}
});
// Line 1 rises left-to-right, line 2 follows from its own edge,
// then the rule draws once the type has landed.
tl.from(chars1, {
yPercent: 120, rotation: 9, opacity: 0,
ease: 'none', stagger: 0.05
}, 0)
.from(chars2, {
yPercent: 120, rotation: -9, opacity: 0,
ease: 'none', stagger: 0.05
}, 0.35)
.to('#slsRule', { width: 'min(420px, 80vw)', ease: 'none', duration: 0.5 }, 1.1);Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Scroll Letter Stagger — Split-Text Headlines Scrubbed by Scroll

The scroll letter stagger is the split-text entrance — each character rising from below its line, straightening as it lands — but scrubbed to the scrollbar instead of played on a timer: letters ride up exactly as far as you've scrolled, and retreat in reverse order when you scroll back. This snippet builds it with GSAP ScrollTrigger (from a CDN), a hand-rolled character splitter, and masked line boxes — no SplitText plugin.
A twelve-line splitter instead of a plugin
Each headline stores its copy in data-text; a small function converts it into inline-block <span class="ch"> elements, mapping spaces to \u00A0 so they keep width. Inline-block is the load-bearing detail — plain inline spans ignore transforms, so every character must become a box before yPercent or rotation can touch it. Reading from an attribute (not innerHTML) also makes the split idempotent: re-running it can't nest spans inside spans.
yPercent + overflow: hidden = the masked rise
Characters start at yPercent: 120 — 120% of *their own height* below their resting spot — while each line has overflow: hidden. The line box acts as a mask: letters are genuinely invisible until they cross their baseline, rising as if from behind a sill. Using yPercent instead of a pixel offset means the hide distance self-scales with the clamped responsive font size, and a small padding-bottom on the line keeps descenders (y, g) from being clipped at rest.
Rotation gives the rise its physicality
Each character also un-rotates from 9° (line one) or −9° (line two) as it lands. Straight vertical rises read as blinds; adding a settling rotation makes letters feel individually *placed*, and mirroring the angle between lines makes the two lines visually answer each other.
Stagger order becomes spatial order under scrub
stagger: 0.05 offsets each character's tween along the timeline, and because the timeline is scrubbed, temporal offset becomes spatial: at any scroll position the left letters are further through their arc than the right ones, producing a wave that sweeps across the word. Scrolling up runs the wave backward — last letter retreats first — for free.
Three beats on one timeline
Line one starts at 0, line two overlaps at 0.35 (starting while line one's tail is still landing, so the headline reads as one gesture), and the gradient rule draws from 0 width at 1.1 — a sign-off that only completes once the type has fully landed. The rule animates width deliberately: it's a single tiny element, so the layout cost is nil and it avoids a scale-x's stretched-gradient artifact.
Gradient text survives the split
The second line's gradient uses background-clip: text applied to the *characters*, so each span carries its slice of the gradient. Applying it to the parent wouldn't survive the split, since transformed inline-blocks establish their own paint containers.
Customizing it
Edit the data-text attributes (the splitter adapts), flip the wave direction with stagger: { each: 0.05, from: 'end' }, or add a third line at a later position. Related type effects: word-level text reveal on scroll, the timer-based split text, scrubbed typing in scroll typewriter, and self-drawing outlines in scroll text draw.
Step by step
How to Use
- 1Add the GSAP CDNsInclude gsap and ScrollTrigger from the CDN panel.
- 2Paste HTML, CSS, and JSHeadlines split into character spans on load.
- 3Scroll into the stageLetters rise from behind their line masks.
- 4Watch the waveThe stagger sweeps left to right across each line.
- 5Scroll back upLetters retreat in reverse order.
- 6Change the copyEdit data-text; the splitter and wave adapt.
Real-world uses
Common Use Cases
yPercent scaling to the responsive font size, and compare with the timer-based split text.Got questions?
Frequently Asked Questions
Each line keeps its copy in a data-text attribute; a small loop creates one inline-block span per character, mapping spaces to non-breaking spaces so they keep width. Inline-block matters because plain inline elements ignore transforms. Reading from the attribute also makes the split idempotent — it can never nest spans on a re-run.
Each character starts at yPercent 120 — 120% of its own height below its resting position — while the line box clips with overflow: hidden. The line acts as a mask, so letters are genuinely hidden until they cross the baseline. yPercent self-scales with the clamp()ed font size, and the line's small bottom padding keeps descenders from being clipped at rest.
stagger: 0.05 offsets each character's tween along the timeline, and scrubbing converts that temporal offset into a spatial one: at any scroll position, earlier letters are further through their rise than later ones. Reversing scroll runs the wave backward automatically. from: 'end' or from: 'center' in a stagger object changes the sweep direction.
background-clip: text paints the element's background through its glyphs, but transformed inline-block spans establish their own paint containers — a parent-level gradient wouldn't show through them. Giving every span the gradient keeps each character carrying its slice, so the line reads as one continuous ramp even mid-animation.
Run the split and timeline in a mount effect — useEffect, onMounted, or ngAfterViewInit — against refs, guarding against double-splitting (StrictMode re-runs) by checking for existing .ch children, and revert a gsap.context in the cleanup so the pin unregisters. Alternatively pre-split in JSX/template by mapping the string to spans, then only the timeline lives in the effect. Line styles port to Tailwind cleanly.