More Scroll Snippets
Scroll Typewriter — Free GSAP Scrubbed Typing Snippet
Scroll Typewriter · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Scroll Typewriter — Type Text With the Scrollbar, Delete It in Reverse

The scroll typewriter turns the classic typing animation into a scrubbed one: instead of playing on a timer, characters appear one by one as you scroll down — and *un-type* when you scroll back up, caret and all. That reversibility is what makes it feel like the scrollbar is the keyboard. This snippet builds it with GSAP ScrollTrigger (from a CDN), a per-character DOM split, and a display-toggling stagger trick.
Lines split into per-character spans at load
Each .stw-line carries its copy in a data-text attribute; JavaScript splits it into individual <span class="ch"> elements, converting spaces to \u00A0 so they don't collapse. Keeping the source text in an attribute (rather than markup) means the split can't double-run into nested spans, and the untyped state renders as genuinely empty lines with a reserved min-height so the terminal never changes size while typing.
display: none is the typing mechanism
Untyped characters are display: none — they occupy no width at all. That's the crucial choice over opacity: 0 or visibility: hidden: invisible-but-present characters would still take space, so the caret couldn't sit at the typing position and text would appear inside a pre-reserved gap. With display, each revealed character genuinely extends the line, exactly like real typing.
A stagger of instant tweens creates stepped, scrubbable typing
display isn't interpolatable, so GSAP applies it at each tween's start. The snippet exploits that: one tl.to(allChars, ...) with duration: 0.0001 and stagger: 1 lays hundreds of near-instant tweens along the timeline like teeth on a gear. As the scrubbed playhead crosses each tween, its character pops in; when the playhead moves backward, ScrollTrigger restores the recorded previous value (none), deleting the character. Reverse typing costs nothing extra — it falls out of GSAP's value-restoration model.
The caret physically follows the typing position
On every timeline update, a loop finds the last visible character and re-appends the caret element directly after it (moving it between lines automatically at the line break). Because hidden characters occupy no space, the caret always hugs the true end of the typed text, while a CSS steps(1) blink keeps it alive during scroll pauses.
Pinned so the typing owns its scroll distance
The terminal pins for +=180% of scroll — enough that each wheel tick types a few characters rather than whole sentences. Widen end for slower, more deliberate typing; the character count is mapped across whatever distance you give it, since the stagger normalizes to the timeline's total duration.
Why a terminal frame
The macOS-style window (traffic lights, mono font, blinking block caret) primes the "typing" metaphor and gives the text a stable, styled container. It's decoration though — the mechanism works on any headline or paragraph.
scrub: true, not a smoothed scrub — on purpose
Most scroll effects in this library use scrub: 0.4 so motion glides after each wheel tick, but typing is a *discrete* effect: characters either exist or don't, so there's no in-between state for smoothing to render. A raw scrub: true locks the typed length exactly to the scrollbar, which keeps the caret honest — pause scrolling and the text is precisely where the playhead says. Smoothing here would only delay character pops after the wheel stops, making the typewriter feel laggy rather than fluid.
Customizing it
Edit the data-text attributes (the split adapts to any length), add more lines, or restyle the caret to a thin bar. For a timer-based version see the typewriter snippet; combine with typing code for code content, or hand off to a scroll text clip reveal for the following section.
Step by step
How to Use
- 1Add the GSAP CDNsInclude gsap and ScrollTrigger from the CDN panel.
- 2Paste HTML, CSS, and JSLines split into hidden per-character spans on load.
- 3Scroll into the terminalIt pins and characters start typing with each tick.
- 4Watch the caretIt rides the end of the typed text across line breaks.
- 5Scroll back upCharacters delete in reverse — scrubbed typing.
- 6Change the copyEdit the data-text attributes; the split adapts.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Each character is a display: none span. A pinned, scrubbed timeline runs one tween per character with duration 0.0001 and stagger 1 — GSAP applies non-tweenable properties like display instantly at each tween's start, so as the scrubbed playhead crosses each tween its character pops in. The playhead position in the timeline is literally the typing position.
When a scrubbed timeline moves backward past a tween's start, GSAP restores the property's recorded previous value — display: none here. So reverse deletion isn't coded anywhere; it falls out of GSAP's value-restoration model. That's also why the caret walks backward correctly: the update callback re-finds the last visible character every tick.
Opacity-hidden characters still occupy width, so the line would be full-length from the start, the caret couldn't sit at the typing position, and text would fill a pre-reserved gap instead of growing. display: none characters take no space, so each reveal genuinely extends the line — the geometry of real typing.
Speed is scroll-mapped, not timed: the trigger's end (+=180%) spreads all characters across that distance, so a longer end means fewer characters per wheel tick. To pace lines differently, split them into separate tweens with position labels, or insert empty tl.to({}, { duration: n }) gaps as pauses between lines.
Yes — the splitter handles any data-text length, and a few hundred spans is trivial for the DOM. For very long content (thousands of characters) split by word instead of character to cut the element count tenfold, and stretch the trigger's end so per-tick typing stays readable. The monospace terminal already suits code; keep angle brackets HTML-escaped inside the attribute so the splitter receives literal text.
Do the character split and timeline in a mount effect (useEffect, onMounted, ngAfterViewInit) against refs, not document queries, and guard against double-splitting under StrictMode by checking for existing .ch spans. Revert a gsap.context in the cleanup so the pin dies on unmount. Keep chars in the DOM rather than state — re-rendering per character would fight the scrub. The terminal shell styles port to Tailwind directly.