How I Built a Scroll-Scrubbed Typewriter Effect With GSAP ScrollTrigger (Full Breakdown)
Most "typewriter" effects on the web play on a timer — a character appears every 60ms whether you're watching or not. The Scroll Typewriter snippet does something different: your scrollbar is the keyboard. Scroll down and the terminal types itself out, character by character. Scroll back up and it deletes itself in perfect reverse, caret and all. Pause anywhere and the text freezes exactly where your scroll position says it should.
Here's the effect live — try scrolling through it, then scrolling back up:
Grab the code, or open the full editor with live HTML/CSS/JS panels: Scroll Typewriter on FWD Tools. The snippet is plain HTML, CSS, and JavaScript, but the same editor also has one-click export buttons for React, Vue, Angular, and React + Tailwind if you'd rather drop it straight into a component-based project than copy raw markup.
In this post I'm going to walk through the whole thing A to Z — what it is, where you'd actually use it, the exact CDN links and GSAP plugin it depends on, and then the full build: the HTML structure, the CSS, and every line of the JavaScript that makes the scrubbing and the reverse-delete work. By the end you'll understand it well enough to rebuild it from scratch or bend it into something of your own.
What the effect actually is
Strip away the terminal chrome and the effect is simple to state: a block of text starts fully hidden. As you scroll through a pinned section, characters reveal themselves one at a time — not on a timer, but mapped directly to your scroll position. Scroll a little, get a few characters. Scroll a lot, get a sentence. Scroll backward, and the exact same mapping runs in reverse: characters disappear in the order they appeared, and a blinking caret tracks the true end of the typed text at all times, even across line breaks.
That reversibility is the whole point. A timer-based typewriter can only play forward. This one is "scrubbed" — tied to an input the user directly controls — which is what makes it feel less like an animation you're watching and more like a machine you're operating with your mouse wheel or trackpad.
Where you'd actually use this
This isn't just a party trick for a portfolio site — a scroll-scrubbed typewriter earns its place in a few specific spots:
- Developer landing pages. Type your value proposition inside a terminal window instead of a static headline — it fits the audience and the aesthetic in one move.
- Hero statements and manifestos. Scrub a short, punchy line-by-line statement as the first thing a visitor sees, then hand off into the next section.
- Product storytelling. Type each chapter heading as readers scroll through a longer narrative page, giving each section its own reveal beat.
- CLI tool demos. Show a command appearing as if it's being typed live, especially effective paired with a fake terminal output block underneath.
- Portfolio introductions. A typed self-introduction reads as more deliberate and crafted than a paragraph that's just sitting there on load.
- Interactive articles. Type out a pull-quote exactly as the reader scrolls to meet it, which is a nice change of pace from a plain fade-in.
In every one of these, the effect works because scrolling is already the thing the user is doing — you're not asking for a new interaction, you're attaching meaning to one they're already performing.
The tech stack: GSAP and one plugin, from a CDN
This snippet leans on GSAP (GreenSock Animation Platform) plus its ScrollTrigger plugin. You could theoretically hand-roll scroll-linked animation with a raw scroll event listener and some math, but ScrollTrigger gives you three things for free that are genuinely hard to get right by hand: robust pinning (locking a section in place while its content plays), scrubbing (mapping a timeline's progress directly to scroll position instead of scroll-triggered playback), and correct behavior across resizes, mobile browser chrome collapsing, and reduced-motion edge cases.
No build step, no npm install required — two script tags from a CDN are enough:
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
Drop those in your <head> or right before your closing </body> tag, before your own script runs. The very first line of the snippet's JavaScript then registers the plugin with the core library:
gsap.registerPlugin(ScrollTrigger);
Skip that line and every ScrollTrigger config you write later is silently ignored — it's the one line that's easy to forget and the first thing to check if a scroll effect just isn't firing.
If GSAP itself is new to you, it's worth spending twenty minutes in the GSAP Playground before diving into ScrollTrigger specifically — it's a free, browser-based course with 55 lessons across 18 chapters covering gsap.to(), timelines, stagger, keyframes, gsap.utils, and a dedicated ScrollTrigger track, each with a live editor and instant preview so you can see exactly what every option does instead of taking this post's word for it.
Building it, step by step
Step 1 — The HTML: text lives in a data attribute, not in markup
The section that gets pinned carries an id the JavaScript will target in a moment, and inside it, the terminal has a macOS-style title bar (three colored dots) for atmosphere. Each line of copy is a paragraph that carries its real text in a data-text attribute rather than as visible content:
<section class="stw-stage" id="stwStage">
<div class="stw-terminal">
<div class="stw-bar"><i></i><i></i><i></i></div>
<div class="stw-body">
<p class="stw-line" data-text="The scrollbar is the keyboard."></p>
<p class="stw-line stw-accent" data-text="Every tick types a character — reverse to delete."></p>
<span class="stw-caret" id="stwCaret"></span>
</div>
</div>
</section>
Why an attribute instead of just typing the sentence between the tags? Because JavaScript is about to split this text into one <span> per character, and it needs a clean, single source of truth to read from. If the text lived directly inside the <p>, re-running the split (say, after a hot-reload in development) could double-split already-split spans into nested spans and quietly break everything. Reading from an attribute means the split is always working from the original, untouched string.
Step 2 — The CSS: display: none is the actual typing mechanism
This is the part that trips people up when they first read the code, so it's worth sitting with: every character span starts as display: none.
.stw-line .ch { display: none; }
Why not opacity: 0, which is the usual go-to for "hidden but reveal-able" content? Because opacity: 0 still occupies layout space — the full sentence would sit there invisibly from the very first frame, the line would already be at its final width, and the caret would have nowhere meaningful to sit. display: none removes the element from layout entirely, so a character genuinely doesn't exist until it's revealed — the line's width grows one character at a time, exactly like real typing.
The container also reserves a min-height up front, so the terminal doesn't grow and shift the page as lines get added — the box is stable from the first frame, even though its content isn't there yet.
Step 3 — The JS: splitting text into character spans
On load, the script walks every .stw-line, reads its data-text, and builds one <span class="ch"> per character:
var lines = document.querySelectorAll('.stw-line');
var allChars = [];
lines.forEach(function (line) {
var text = line.getAttribute('data-text');
for (var i = 0; i < text.length; i++) {
var span = document.createElement('span');
span.className = 'ch';
span.textContent = text[i] === ' ' ? '\u00A0' : text[i];
line.appendChild(span);
allChars.push(span);
}
});
One small but important detail: plain space characters get swapped for '\u00A0' (a non-breaking space). Regular spaces inside individually-wrapped inline spans can collapse or get trimmed by the browser depending on context — a non-breaking space guarantees every gap in the sentence actually renders, even mid-word-wrap.
Every span, across every line, gets pushed into one flat array — allChars — because the animation that follows doesn't care about line boundaries. It just needs one long list of "things to reveal in order."
Step 4 — The GSAP timeline: pin, scrub, and the stagger trick
This is the core of the whole effect, and it's shorter than you'd expect:
var tl = gsap.timeline({
scrollTrigger: {
trigger: '#stwStage',
start: 'top top',
end: '+=180%',
scrub: true,
pin: true
}
});
tl.to(allChars, {
display: 'inline',
duration: 0.0001,
stagger: 1,
ease: 'none'
});
Let's take the ScrollTrigger config first. pin: true locks the terminal in place on screen once you scroll to it, so it doesn't drift away while typing. start: 'top top' means pinning begins the instant the section's top hits the viewport's top. end: '+=180%' means the pin — and the whole typing animation — plays out over 180% of the viewport's height worth of scrolling. And scrub: true is the single most important line here: it means the timeline's progress is tied directly to scroll position, not played once when triggered. Scroll 10% of the way through that 180%, and the timeline is exactly 10% complete. Scroll backward, and the timeline runs backward too.
Now the animation itself, which uses a trick worth understanding on its own: display is not a property GSAP (or CSS, for that matter) can smoothly interpolate — a value is either none or inline, with nothing in between. GSAP's answer to non-tweenable properties is to apply them instantly at the start of their tween. So a tween with duration: 0.0001 effectively becomes an instant on/off switch, fired at one precise point along the timeline.
stagger: 1 is what turns one tween targeting hundreds of characters into hundreds of individually-timed instant switches, spaced evenly across the whole timeline — like teeth on a gear. As the scrubbed playhead crosses each tooth, that one character's display flips to inline and it appears. Scroll a little, cross a few teeth, get a few characters. That's the entire "typing" mechanism — no setInterval, no manual counting, no timer of any kind.
Step 5 — Why scrolling up deletes text for free
Here's the part that feels like magic the first time you see it: reverse deletion isn't code anyone wrote. It's a direct consequence of how GSAP handles scrubbing. When a scrubbed timeline's playhead moves backward past a tween's start point, GSAP doesn't just stop — it restores that property to whatever value it recorded before the tween ran. For every character tween, that recorded "before" value is display: none. So scrolling up past a character's tooth simply undoes it, restoring the hidden state. The entire delete animation is really just GSAP's own undo mechanism, and it costs zero extra code.
Step 6 — Making the caret follow the typing position
A typewriter without a moving cursor doesn't read as a typewriter. The caret is handled with an onUpdate callback that runs on every single frame of the scrub:
onUpdate: function () {
var last = null;
for (var i = allChars.length - 1; i >= 0; i--) {
if (allChars[i].style.display === 'inline') { last = allChars[i]; break; }
}
var host = last ? last.parentNode : lines[0];
host.appendChild(caret);
}
On every update, this walks backward through the full character list until it finds the last one currently visible, then physically re-appends the caret <span> right after it in the DOM. Because that "last visible character" naturally changes as you scroll — including jumping from the end of one line to the start of the next — the caret rides along automatically, with no separate line-tracking logic required. A CSS steps(1) keyframe blink keeps it alive whenever you pause scrolling, so it doesn't look frozen mid-thought.
Customizing it for your own project
A few knobs worth knowing about if you're adapting this:
- Change the copy — just edit the
data-textattributes. The character split adapts to any length automatically; add as many.stw-lineparagraphs as you want. - Control the pacing — typing speed is scroll-mapped, not timed, so a longer
endvalue (say+=300%) spreads the same characters across more scroll distance, making each tick type fewer characters and feel more deliberate. - Longer content — the splitter handles paragraphs of any length fine; for genuinely long content (thousands of characters), consider splitting by word instead of character to cut the element count down, and stretch the trigger's
endso per-tick typing stays readable. - A different container — the terminal chrome (traffic-light dots, monospace font) is pure decoration on top of the mechanism. Strip it out and the same character-split-plus-scrub technique works on any headline or paragraph.
Using it in React, Vue, or Angular
If you're not working in plain HTML/CSS/JS, the one rule that matters is: do the character split and the timeline setup inside a mount lifecycle hook — useEffect in React, onMounted in Vue, ngAfterViewInit in Angular — never at module or render time, since the DOM elements need to exist first. Query against refs rather than document.querySelector where possible, and in React specifically, guard against double-running the split under Strict Mode by checking whether .ch spans already exist before creating new ones. On unmount, revert a gsap.context() (or manually kill the ScrollTrigger instance) so the pin doesn't linger after the component is gone. Keep every character as a real DOM node rather than component state — re-rendering per character would fight the scrub and tank performance.
The FWD Tools snippet editor has one-click exports for all of this already built if you'd rather skip writing the conversion by hand — grab the React, Vue, Angular, or Tailwind version directly from the snippet page.
Final thought
What makes this snippet worth studying isn't the typewriter effect itself — it's the underlying pattern: GSAP's scrub option turns any timeline into something a user can rewind and fast-forward with their own scroll gesture, and the "instant tween as a switch" trick unlocks properties (display, visibility, even class toggles via onStart/onReverseComplete) that don't normally animate at all. Once that clicks, you start seeing scroll-scrubbed versions of effects everywhere — not just typing, but reveals, step-throughs, and state machines, all driven by the same handful of lines.
Grab the full HTML, CSS, and JS — or export it straight to React, Vue, Angular, or Tailwind — at Scroll Typewriter on FWD Tools. It's free, runs entirely in your browser, and needs no sign-up. Browse more scroll-driven effects like this one at FWD Tools UI Snippets — Scroll Effects.
