Back to Gallery f07

Scroll Text Highlight

FREE

Scroll-linked reading highlight that lights a paragraph word by word as you scroll, with the leading word flashing an accent colour before settling to full foreground.

ScrollTriggerSplitText scroll-revealscrubtext-animation Lenis intermediate

About This Effect

A scroll-linked text highlight effect that makes a block of copy read itself. SplitText breaks the paragraph into words and a single scrubbed ScrollTrigger drives a timeline that brightens each word from dim to full in sequence, so a moving band of highlighted words tracks your scroll position: dim text ahead of you, settled text behind.

The leading word flashes the accent colour before relaxing to the foreground, giving a clean reading front rather than a one-shot fade-in. Because it is scrubbed, scrolling back up smoothly un-lights the words, and it works natively on touch with no separate mobile path.

What's Included

  • Scroll-scrubbed word-by-word highlight driven by one ScrollTrigger, not a one-shot reveal
  • Leading word flashes the accent colour then relaxes to the foreground for a crisp reading front
  • Fully reversible: scrolling up smoothly dims words back to their resting state
  • SplitText word splitting with document.fonts.ready so word widths and line wraps measure correctly
  • Tunable dim level, accent flash, optional word lift and scrub smoothing via data attributes
  • Works on any block of copy: editorial paragraphs or large display headings, no markup changes
  • Native touch support with no separate mobile code path, legible down to 375px
  • Accessible: reduced-motion and no-JS readers always get full-opacity copy, never stuck dim

Perfect For

  • Editorial and long-form article intros where you want the reader to slow down
  • Agency and studio manifesto sections that reveal a statement at reading pace
  • Product landing pages spotlighting a mission or value proposition paragraph
  • About and story pages guiding attention through a single considered sentence
  • Case study openers that walk the eye through the setup before the results

How It Works

On load the effect waits for document.fonts.ready, then SplitText splits each .scroll-highlight block into word spans. A single gsap.timeline is created with a scrubbed ScrollTrigger over the block, and each word gets a fromTo tween that lifts it from a dim opacity to full while shifting its colour to the accent, offset along the timeline by a fixed step so the tweens overlap into a moving band.

A second tween relaxes each word's colour from the accent back to the foreground a beat later, leaving read words calm behind the front. immediateRender seeds every word to its dim start state so words ahead of the front stay dimmed, and gsap.matchMedia routes reduced-motion readers to a full-opacity static state while the dimming only ever comes from JS, so no-JS readers keep legible copy.

Plugins ScrollTrigger, SplitText
Difficulty Intermediate
Smooth Scroll Lenis Integration
Includes HTML + JS + CSS source, documentation, lifetime updates
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <title>Scroll Text Highlight Demo | GSAP Vault</title>
    <link rel="stylesheet" href="assets/style.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
    <main class="container">
        <header class="page-header">
            <h1 class="page-title">Scroll Text Highlight</h1>
            <p class="page-subtitle">Copy that reads itself: a band of words lights up word by word as you scroll</p>
        </header>

        <!-- Example 1: Reading block -->
        <section class="example" data-thumbnail-target>
            <div class="example-header">
                <span class="example-number">01</span>
                <h2 class="example-title">Reading Block</h2>
            </div>
            <div class="example-content">
                <p class="example-instruction">Scroll slowly: the copy lights up as you read, and dims again if you scroll back.</p>
                <p class="scroll-highlight reading-block">
                    We have forgotten how to read slowly. The screen trained us to skim, to hunt for the one line that matters and discard the rest before it can land. But some ideas only give themselves up at reading pace, one word at a time, in the exact order the writer set them. This is what attention feels like when you give it fully: a single lit word, then the next, and the sentence assembling itself in front of you. Nothing on this page moves faster than you do. Slow down, and the meaning arrives.
                </p>
            </div>
        </section>

        <!-- Example 2: Statement heading (same mechanism, different scale) -->
        <section class="example">
            <div class="example-header">
                <span class="example-number">02</span>
                <h2 class="example-title">Statement Heading</h2>
            </div>
            <div class="example-content">
                <p class="example-instruction">The same scrubbed highlight on display type. Keep scrolling to light the line:</p>
                <h2 class="scroll-highlight statement">Design is the quiet art of deciding what a reader notices first.</h2>
            </div>
        </section>

        <!-- Scroll runway: gives the final block room to finish lighting before
             the page bottom. Not part of the effect; remove on your own page if
             the highlighted block already has content scrolling after it. -->
        <div class="scroll-runway" aria-hidden="true"></div>
    </main>

    <!-- GSAP Core -->
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
    <!-- SplitText plugin (free since GSAP 3.12) -->
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/SplitText.min.js"></script>

    <!-- Optional: Lenis smooth scroll -->
    <script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script>

    <!-- Effect code -->
    <script src="assets/script.js"></script>
</body>
</html>
/**
 * Scroll Text Highlight
 *
 * Scroll-linked reading highlight: SplitText breaks a block of copy into
 * words, and one scrubbed ScrollTrigger drives a timeline that lights each
 * word from dim to full in sequence. The leading word flashes the accent
 * colour, then relaxes to the foreground, so a bright reading band moves
 * through the paragraph as you scroll. Fully reversible on scroll-up.
 *
 * @plugins ScrollTrigger, SplitText
 * @techniques scroll-reveal, scrub, text-animation
 */

gsap.registerPlugin(ScrollTrigger, SplitText);

/* Runs the init straight away if the DOM is already parsed (a script
   executed late or deferred, e.g. by Cloudflare Rocket Loader), and
   waits for DOMContentLoaded otherwise. A bare DOMContentLoaded listener
   silently never fires under deferred execution. */
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initScrollTextHighlight() {
    const BLOCKS = '.scroll-highlight';

    /* No-JS / no-plugin readers never reach this file, and if SplitText is
       missing we must not leave the copy dimmed. The dim state is only ever
       applied by GSAP below, so simply bailing keeps the text fully legible. */
    if (typeof SplitText === 'undefined') {
        return;
    }

    /* OPTIONAL: Lenis smooth scroll. A scrubbed reading effect benefits from
       it; delete this block (and the CDN tag) for native scroll. */
    let lenis = null;
    if (typeof Lenis !== 'undefined') {
        lenis = new Lenis({ autoRaf: true });
        lenis.on('scroll', ScrollTrigger.update);
    }
    /* END OPTIONAL: Lenis */

    const splits = [];

    // Timeline shape (in abstract units, scrub maps them to scroll distance):
    // STEP   - spacing between one word lighting and the next
    // LIGHT  - how long a word takes to reach full accent (the front's sharpness)
    // SETTLE - how long the accent relaxes back to foreground (the band's tail)
    // A wider SETTLE lights more words at once; too wide and the "front" blurs.
    const STEP = 1;
    const LIGHT = 1.4;
    const SETTLE = 2.6;

    function resolveColor(el, value) {
        // Resolve a CSS custom property or keyword to a concrete rgb() string
        // via a throwaway probe, since GSAP cannot interpolate to var(--x).
        const probe = document.createElement('span');
        probe.style.cssText = 'position:absolute;visibility:hidden;color:' + value;
        el.appendChild(probe);
        const color = getComputedStyle(probe).color;
        probe.remove();
        return color;
    }

    function buildBlock(container) {
        if (!container.isConnected) return;

        const CONFIG = {
            // Resting opacity of un-read words (ahead of and, briefly, behind the front)
            dim: parseFloat(container.dataset.highlightDim) || 0.18,
            // Leading word flashes the accent colour before settling to foreground
            accent: container.dataset.highlightAccent !== 'false',
            // Optional restrained secondary channel: px the word lifts as it lights
            lift: parseFloat(container.dataset.highlightLift) || 0,
            // Scrub smoothing: true snaps to scroll, a number adds catch-up lag
            scrub: container.dataset.highlightScrub === undefined
                ? true
                : (container.dataset.highlightScrub === 'true'
                    ? true
                    : parseFloat(container.dataset.highlightScrub))
        };

        const split = new SplitText(container, {
            type: 'words',
            wordsClass: 'sh-word'
        });
        splits.push(split);

        const words = split.words;
        if (!words.length) {
            split.revert();
            return;
        }

        const accentColor = resolveColor(container, 'var(--accent)');
        const fgColor = getComputedStyle(container).color;
        const litColor = CONFIG.accent ? accentColor : fgColor;

        // Class is a CSS hook only; the dim state itself is applied by GSAP
        // below, so a reader without JS keeps full-opacity copy.
        container.classList.add('is-reading');

        const tl = gsap.timeline({
            defaults: { ease: 'none' },
            scrollTrigger: {
                trigger: container,
                // Finish while the block is still comfortably in view (its bottom
                // at 65% of the viewport) so the last words light before the reader
                // runs out of scroll. The demo adds a runway spacer after the final
                // block so this end is always reachable; give any trailing block on
                // your own page similar room below it.
                start: 'top 80%',
                end: 'bottom 65%',
                scrub: CONFIG.scrub
            }
        });

        words.forEach(function(word, i) {
            const at = i * STEP;
            // Phase A: dim -> full, foreground -> lit (accent). immediateRender
            // seeds every word to the dim "from" state, so words ahead of the
            // front sit dimmed before their tween is reached.
            tl.fromTo(word,
                { opacity: CONFIG.dim, y: CONFIG.lift },
                { opacity: 1, y: 0, color: litColor, duration: LIGHT },
                at
            );
            // Phase B: relax the accent back to the settled foreground colour,
            // leaving read words full and calm behind the moving front.
            if (CONFIG.accent) {
                tl.to(word,
                    { color: fgColor, duration: SETTLE },
                    at + LIGHT
                );
            }
        });
    }

    const ctx = gsap.context(function gsapContextCallback() {
        const mm = gsap.matchMedia();

        mm.add('(prefers-reduced-motion: no-preference)', function() {
            // Wait for fonts so SplitText measures final word widths and wraps
            // lines correctly; refresh ScrollTrigger once positions are set.
            document.fonts.ready.then(function() {
                document.querySelectorAll(BLOCKS).forEach(buildBlock);
                ScrollTrigger.refresh();
            });

            return function cleanup() {
                ScrollTrigger.getAll().forEach(function(trigger) {
                    trigger.kill();
                });
                splits.forEach(function(split) {
                    split.revert();
                });
                splits.length = 0;
            };
        });

        mm.add('(prefers-reduced-motion: reduce)', function() {
            // Never leave copy stuck dim: show every block at full opacity.
            document.querySelectorAll(BLOCKS).forEach(function(block) {
                gsap.set(block, { opacity: 1 });
            });
        });
    });

    window.gsapContext = ctx;

    window.addEventListener('beforeunload', function() {
        if (ctx) ctx.kill();
        if (lenis) lenis.destroy();
    });
});
gsap.registerPlugin(ScrollTrigger,SplitText),function(t){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):t()}(function(){const t=".scroll-highlight";if("undefined"==typeof SplitText)return;let e=null;"undefined"!=typeof Lenis&&(e=new Lenis({autoRaf:!0}),e.on("scroll",ScrollTrigger.update));const o=[];function n(t){if(!t.isConnected)return;const e={dim:parseFloat(t.dataset.highlightDim)||.18,accent:"false"!==t.dataset.highlightAccent,lift:parseFloat(t.dataset.highlightLift)||0,scrub:void 0===t.dataset.highlightScrub||("true"===t.dataset.highlightScrub||parseFloat(t.dataset.highlightScrub))},n=new SplitText(t,{type:"words",wordsClass:"sh-word"});o.push(n);const r=n.words;if(!r.length)return void n.revert();const i=function(t,e){const o=document.createElement("span");o.style.cssText="position:absolute;visibility:hidden;color:"+e,t.appendChild(o);const n=getComputedStyle(o).color;return o.remove(),n}(t,"var(--accent)"),c=getComputedStyle(t).color,a=e.accent?i:c;t.classList.add("is-reading");const l=gsap.timeline({defaults:{ease:"none"},scrollTrigger:{trigger:t,start:"top 80%",end:"bottom 65%",scrub:e.scrub}});r.forEach(function(t,o){const n=1*o;l.fromTo(t,{opacity:e.dim,y:e.lift},{opacity:1,y:0,color:a,duration:1.4},n),e.accent&&l.to(t,{color:c,duration:2.6},n+1.4)})}const r=gsap.context(function(){const e=gsap.matchMedia();e.add("(prefers-reduced-motion: no-preference)",function(){return document.fonts.ready.then(function(){document.querySelectorAll(t).forEach(n),ScrollTrigger.refresh()}),function(){ScrollTrigger.getAll().forEach(function(t){t.kill()}),o.forEach(function(t){t.revert()}),o.length=0}}),e.add("(prefers-reduced-motion: reduce)",function(){document.querySelectorAll(t).forEach(function(t){gsap.set(t,{opacity:1})})})});window.gsapContext=r,window.addEventListener("beforeunload",function(){r&&r.kill(),e&&e.destroy()})});
/* ============================================
   GSAP VAULT DESIGN SYSTEM
   ============================================ */

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    /* Core colors */
    --black: #0a0a0a;
    --dark: #111111;
    --surface: #1a1a1a;
    --surface-light: #242424;
    --border: #2a2a2a;
    --border-light: #3a3a3a;

    /* Text hierarchy */
    --text: #ffffff;
    --text-secondary: #b0b0b0;
    --text-muted: #999999;

    /* Accent colors */
    --lime: #c8ff00;
    --lime-dim: #a8d900;
    --purple: #a855f7;
    --purple-light: #c084fc;
    --orange: #ff6b35;
    --orange-light: #ff8c5a;
    --cyan: #22d3ee;
    --pink: #ff3366;

    /* Set chosen accent */
    --accent: var(--lime);

    /* Easing - matches GSAP patterns */
    --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
    --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
    --ease-out-cubic: cubic-bezier(0.33, 1, 0.68, 1);
}

html {
    scroll-behavior: auto;
}

body {
    background: var(--black);
    color: var(--text);
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 400;
    overflow-x: hidden;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    line-height: 1.6;
}

::selection {
    background: var(--accent);
    color: var(--black);
}

/* ============================================
   CUSTOM SCROLLBAR
   ============================================ */
::-webkit-scrollbar {
    width: 10px;
}

::-webkit-scrollbar-track {
    background: var(--black);
}

::-webkit-scrollbar-thumb {
    background: var(--surface-light);
    border-radius: 5px;
    border: 2px solid var(--black);
}

::-webkit-scrollbar-thumb:hover {
    background: var(--border-light);
}

/* ============================================
   TYPOGRAPHY
   ============================================ */
h1, h2, h3, h4, h5, h6 {
    font-family: 'Syne', system-ui, sans-serif;
    font-weight: 700;
    line-height: 1.1;
    letter-spacing: -0.03em;
}

code {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.85em;
    background: var(--surface);
    padding: 0.15em 0.4em;
    border-radius: 4px;
}

/* ============================================
   LAYOUT
   ============================================ */
.container {
    max-width: 1000px;
    margin: 0 auto;
    padding: 5rem 2rem;
}

/* ============================================
   PAGE HEADER
   ============================================ */
.page-header {
    text-align: center;
    margin-bottom: 6rem;
    padding-top: 2rem;
}

.page-title {
    font-size: clamp(2.5rem, 7vw, 4rem);
    font-weight: 700;
    letter-spacing: -0.03em;
    margin-bottom: 1rem;
}

.page-subtitle {
    font-size: 1.125rem;
    color: var(--text-secondary);
    max-width: 560px;
    margin: 0 auto;
}

/* ============================================
   EXAMPLE SECTIONS
   ============================================ */
.example {
    margin-bottom: 8rem;
    padding-bottom: 4rem;
    border-bottom: 1px solid var(--border);
}

.example:last-child {
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: none;
}

/* Scroll runway so the final highlighted block can reach its ScrollTrigger end
   (bottom 65%) before the page bottom, and finish lighting every word. */
.scroll-runway {
    height: 60vh;
}

.example-header {
    display: flex;
    align-items: center;
    gap: 1rem;
    margin-bottom: 2rem;
}

.example-number {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--accent);
    letter-spacing: 0.1em;
}

.example-title {
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-size: 0.875rem;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-muted);
}

.example-content {
    padding-left: 3rem;
}

.example-instruction {
    font-size: 1rem;
    color: var(--text-secondary);
    margin-bottom: 2rem;
    line-height: 1.7;
}

/* ============================================
   EFFECT: SCROLL TEXT HIGHLIGHT
   ============================================ */

/* Shared: individual word spans SplitText produces. inline-block keeps the
   optional y-lift transform working; words still wrap naturally. */
.sh-word {
    display: inline-block;
    will-change: opacity, transform;
}

/* The editorial reading block. Generous size and measure so the moving
   highlight front has room to read as a band of a few words. */
.reading-block {
    font-size: clamp(1.5rem, 3.4vw, 2.4rem);
    font-family: 'Syne', system-ui, sans-serif;
    font-weight: 600;
    line-height: 1.5;
    letter-spacing: -0.02em;
    color: var(--text);
    max-width: 22ch;
    margin: 0 auto;
    /* Breathing room above and below so the scrub has scroll distance to
       walk the front through the whole paragraph. */
    padding: 30vh 0;
}

/* Statement heading: the same mechanism on display type. */
.statement {
    font-size: clamp(2rem, 6vw, 3.75rem);
    font-weight: 800;
    line-height: 1.14;
    letter-spacing: -0.03em;
    color: var(--text);
    max-width: 16ch;
    padding: 22vh 0;
}

/* ============================================
   RESPONSIVE
   ============================================ */
@media (max-width: 768px) {
    .container {
        padding: 3rem 1.5rem;
    }

    .page-header {
        margin-bottom: 4rem;
    }

    .example {
        margin-bottom: 5rem;
        padding-bottom: 3rem;
    }

    .example-content {
        padding-left: 0;
    }

    .reading-block {
        max-width: none;
        line-height: 1.55;
        padding: 22vh 0;
    }

    .statement {
        max-width: none;
        padding: 16vh 0;
    }
}

/* ============================================
   REDUCED MOTION
   ============================================ */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }

    /* Belt and braces: never let the copy sit dimmed for reduced-motion
       readers, whatever the JS did. */
    .scroll-highlight,
    .sh-word {
        opacity: 1 !important;
    }
}

/* ============================================
   SCREEN READER ONLY
   ============================================ */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

A scroll-linked reading highlight. SplitText breaks a block of copy into words, and one scrubbed ScrollTrigger lights each word from dim to full as you scroll, so a bright band of highlighted words moves through the paragraph: dim text ahead of you, settled text behind. The leading word flashes the accent colour before relaxing to the foreground, and scrolling back up smoothly un-lights the words.

Quick Start

1. Add to your HTML <head>:

<link rel="stylesheet" href="path/to/style.css">

2. Add before the closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/SplitText.min.js"></script>
<script src="path/to/script.js"></script>

Note: GSAP and all plugins (including SplitText) are free for everyone. Lenis is optional; add <script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script> before script.js for smooth scrolling.

3. Add the scroll-highlight class to any block of copy in your <body>:

<p class="scroll-highlight">
  Some copy that lights up word by word as the reader scrolls it into view.
</p>

That is the whole setup. The block starts dimmed and lights up as it scrolls through the viewport. It works the same on a paragraph or a large heading.

Options

Set these as data attributes on the .scroll-highlight element.

Attribute Values Default Description
data-highlight-dim 0 to 1 0.18 Resting opacity of un-read words
data-highlight-accent true, false true Whether the leading word flashes the accent colour before settling
data-highlight-lift Any number (px) 0 Optional restrained lift each word rises as it lights
data-highlight-scrub true or a number true true locks the highlight to scroll; a number (seconds) adds smoothing catch-up

The accent colour comes from the --accent CSS variable, so restyle it in one place:

.scroll-highlight { --accent: #c8ff00; }

Examples

Editorial paragraph (default)

HTML:

<p class="scroll-highlight">
  We have forgotten how to read slowly. Some ideas only give themselves
  up at reading pace, one word at a time, in the order the writer set them.
</p>

Statement heading

HTML:

<h2 class="scroll-highlight">
  Design is the quiet art of deciding what a reader notices first.
</h2>

Deeper dim and a subtle word lift

HTML:

<p class="scroll-highlight" data-highlight-dim="0.1" data-highlight-lift="6">
  A stronger contrast between read and unread copy, with each word
  lifting a few pixels as it brightens.
</p>

No accent flash, softer scrub

HTML:

<p class="scroll-highlight" data-highlight-accent="false" data-highlight-scrub="0.6">
  Just a clean dim-to-full brightening, with the highlight easing to
  catch up to the scroll position.
</p>

CSS Classes

Class Description
.scroll-highlight The block of copy to highlight on scroll
.sh-word Applied by SplitText to each generated word span
.is-reading Added by the script once the effect is live (CSS hook)

Accessibility

  • Reduced motion: readers with prefers-reduced-motion: reduce see the full copy at full opacity immediately, with no dimming or scrubbing.
  • No JavaScript / no SplitText: the dim state is only ever applied by GSAP, so a reader without JavaScript (or without the SplitText plugin) always gets plain, full-opacity, fully legible text.
  • Native scroll: the effect is driven purely by scroll position, so it works with keyboard scrolling and on touch with no separate code path.
  • Readable throughout: even the dimmed state keeps text on its resting foreground colour, so nothing is hidden or unselectable.

Performance Notes

  • One SplitText split and one scrubbed ScrollTrigger timeline per block.
  • Word spans are display: inline-block with will-change: opacity, transform so the lift and fade stay on the compositor.
  • The script waits for document.fonts.ready before splitting, so words measure and wrap at their final widths, then calls ScrollTrigger.refresh().

Dependencies

Required:

  • GSAP 3.12+
  • ScrollTrigger plugin
  • SplitText plugin (free)

Optional:

  • Lenis (smooth scroll integration)

Your Cart

Your cart is empty

Browse Effects