f07

Scroll Text Highlight

Free

A scrubbed orange-to-lime reading front lifts each active word before completed copy settles to white and unread copy remains ghosted.

ScrollTriggerSplitText Lenis intermediate
3 more details
scroll-revealscrubtext-animation

This demo reads better at your own screen size than in the frame below:

Released

About this effect

An immersive scroll-linked text highlight effect that turns one editorial statement into a deliberate reading sequence. SplitText breaks the copy into words while a scrubbed ScrollTrigger advances a sharp orange-to-lime front: unread words remain ghosted, the active word flashes and lifts, and completed words settle to calm white.

Read the full effect overview

The sequence is fully reversible and can drive a restrained progress rail or word coordinate from the same scroll position. Use the block itself as the trigger for drop-in copy, or point the preserved data-attribute API at a larger sticky stage for a composed manifesto moment.

Scroll Text Highlight - GSAP animation effect preview

What's included

8 items
  • Sharp orange-to-lime active-word front with a subtle vertical lift
  • Unread words show a faint ghost preview and reveal their real text as the front reads them
  • Fully scrubbed and reversible through one ScrollTrigger timeline
  • Optional progress rail and completed-word coordinate tied to the same scroll
  • Reusable SplitText data-attribute API for colours, lift, dim level, scrub and trigger range
  • Font-aware splitting with document.fonts.ready and refresh-safe measurements
  • Compact mobile scroll distance with responsive editorial wrapping
  • Complete static statement for reduced motion, blocked CDN and no-JavaScript readers

Perfect for

5 use cases
  • Editorial manifesto openings that reward deliberate reading
  • Agency and studio statements with a strong guided-attention moment
  • Campaign landing pages that pace a concise central message
  • Article intros where scroll position should mirror reading progress
  • About pages that need one immersive text-led section

How it works

2 sections

After fonts load, SplitText wraps each word, moves the real text into an inner span and copies the word onto a data-mark attribute that a ::before paints as the faint ghost preview. A timeline crossfades each word from ghost to full ink through a brief orange edge, lime lift and calm settle; one scrubbed ScrollTrigger maps that timeline to either the text block or an optional external sticky-stage trigger.

The trigger update also scales an optional progress element and updates an optional word coordinate, keeping every response channel reversible from the same scroll value. An exhaustive motion/reduced-motion matchMedia condition set keeps the static fallback complete, while cleanup kills triggers, reverts splits, removes the Lenis ticker and unregisters its named refresh listener.

Plugins ScrollTrigger, SplitText
Difficulty Intermediate
Smooth scroll Lenis integration
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Scroll Text Highlight Demo | GSAP Vault</title>
    <link rel="stylesheet" href="assets/style.css">
</head>
<body>
    <main class="reading-sequence" data-reading-sequence data-thumbnail-target>
        <section class="reading-stage" aria-label="Scroll-controlled editorial statement">
            <header class="stage-meta" aria-hidden="true">
                <p class="scroll-cue"><span></span>Scroll to read</p>
            </header>

            <p class="scroll-highlight manifesto"
               data-highlight-dim="0.3"
               data-highlight-lift="8"
               data-highlight-trigger="[data-reading-sequence]"
               data-highlight-progress="[data-reading-progress]"
               data-highlight-current="[data-reading-current]">
                Attention is not captured. It is chosen. Read deliberately, one word at a time, until the noise falls away and meaning has nowhere left to hide.
            </p>

            <footer class="reading-coordinate" aria-hidden="true">
                <span class="coordinate-label">Reading sequence</span>
                <span class="progress-track"><span class="progress-fill" data-reading-progress></span></span>
                <span class="coordinate-count" data-reading-current>26 / 26</span>
            </footer>
        </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
    <script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script>
    <script src="assets/script.js"></script>
</body>
</html>
/**
 * Scroll Text Highlight
 *
 * SplitText turns each highlighted block into a reversible reading sequence.
 * Every word holds two layers in one box: a faint ghost preview painted from
 * a data-mark attribute, and the real text, hidden until the reading front
 * arrives. A sharp orange-to-lime front lifts the active word, then leaves
 * completed words calm and dark. Optional progress and coordinate elements can follow
 * the same scrubbed ScrollTrigger.
 *
 * @plugins ScrollTrigger, SplitText
 * @techniques scrub, text-animation, scroll-highlight
 */

(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initScrollTextHighlight() {
    const BLOCKS = '.scroll-highlight';

    /* A blocked CDN must leave the unsplit, fully readable statement alone. */
    if (typeof gsap === 'undefined'
        || typeof ScrollTrigger === 'undefined'
        || typeof SplitText === 'undefined') {
        return;
    }

    gsap.registerPlugin(ScrollTrigger, SplitText);

    const wantsSmooth = (new URLSearchParams(location.search).get('smooth')
        || document.documentElement.dataset.smooth) !== 'off'
        && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    let lenis = null;
    let lenisTick = null;
    let syncLenisOnRefresh = null;

    if (wantsSmooth && typeof Lenis !== 'undefined') {
        lenis = new Lenis({ autoRaf: false });
        lenis.on('scroll', ScrollTrigger.update);
        lenisTick = function (time) { lenis.raf(time * 1000); };
        syncLenisOnRefresh = function () {
            if (lenis) {
                lenis.scrollTo(window.scrollY, { immediate: true, force: true });
            }
        };
        gsap.ticker.add(lenisTick);
        gsap.ticker.lagSmoothing(0);
        ScrollTrigger.addEventListener('refresh', syncLenisOnRefresh);
    }

    const splits = [];
    const STEP = 1;
    const EDGE = 0.16;
    const FLASH = 0.28;
    const SETTLE = 0.72;

    function numericAttribute(element, name, fallback) {
        const value = parseFloat(element.dataset[name]);
        return Number.isFinite(value) ? value : fallback;
    }

    function resolveColor(element, value) {
        const probe = document.createElement('span');
        probe.style.cssText = 'position:absolute;visibility:hidden;color:' + value;
        element.appendChild(probe);
        const color = getComputedStyle(probe).color;
        probe.remove();
        return color;
    }

    function resolveElement(value, container) {
        if (!value) return null;
        try {
            return document.querySelector(value) || container.closest(value);
        } catch (error) {
            return null;
        }
    }

    /* The visible block is one word per element mid-animation, which reads as
       twenty-six fragments to a screen reader. Speak the sentence once from a
       hidden sibling and hide the fragments. */
    function announce(container) {
        if (container.previousElementSibling
            && container.previousElementSibling.hasAttribute('data-sh-sr')) return;
        const sr = document.createElement('p');
        sr.setAttribute('data-sh-sr', '');
        sr.textContent = container.textContent.replace(/\s+/g, ' ').trim();
        sr.style.cssText = 'position:absolute;width:1px;height:1px;margin:-1px;'
            + 'padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);'
            + 'white-space:nowrap;';
        container.parentNode.insertBefore(sr, container);
        container.setAttribute('aria-hidden', 'true');
    }

    function unannounce() {
        document.querySelectorAll('[data-sh-sr]').forEach(function (node) {
            const block = node.nextElementSibling;
            if (block) block.removeAttribute('aria-hidden');
            node.remove();
        });
    }

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

        const CONFIG = {
            dim: numericAttribute(container, 'highlightDim', 0.16),
            accent: container.dataset.highlightAccent !== 'false',
            lift: numericAttribute(container, 'highlightLift', 7),
            scrub: container.dataset.highlightScrub === undefined
                ? true
                : (container.dataset.highlightScrub === 'true'
                    ? true
                    : numericAttribute(container, 'highlightScrub', true))
        };

        const trigger = resolveElement(container.dataset.highlightTrigger, container) || container;
        const progress = resolveElement(container.dataset.highlightProgress, container);
        const current = resolveElement(container.dataset.highlightCurrent, container);
        /* aria: 'none' — SplitText's default puts an aria-label on the block, and
           aria-label is prohibited on a <p> with no role. The readable sentence is
           carried by a visually hidden sibling instead, and the split words are
           taken out of the accessibility tree. */
        const split = new SplitText(container, { type: 'words', wordsClass: 'sh-word', aria: 'none' });
        splits.push(split);
        announce(container);

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

        /* Two layers, one box. The ghost preview is generated content painted
           from data-mark, so it is decoration with no text node; the real text
           moves into .sh-word__text and stays visibility:hidden until the
           front reads it, then crossfades in as the ghost fades out. */
        const texts = words.map(function (word) {
            const text = document.createElement('span');
            text.className = 'sh-word__text';
            while (word.firstChild) {
                text.appendChild(word.firstChild);
            }
            word.appendChild(text);
            word.setAttribute('data-mark', text.textContent);
            return text;
        });

        const foreground = getComputedStyle(container).color;
        const accent = CONFIG.accent ? resolveColor(container, 'var(--accent)') : foreground;
        const edge = CONFIG.accent ? resolveColor(container, 'var(--highlight-edge, #ff6b35)') : foreground;
        const usesStageTrigger = trigger !== container;

        container.classList.add('is-reading');
        /* The ghost opacity rides a custom property, because a pseudo-element
           cannot be tweened directly. GSAP removes an inline custom property
           when it reverts a tween (every ScrollTrigger refresh does), so the
           unread value also lives on the block as --sh-dim and the ::before
           falls back to it. */
        container.style.setProperty('--sh-dim', String(CONFIG.dim));
        gsap.set(words, { y: CONFIG.lift });
        gsap.set(texts, { autoAlpha: 0, color: foreground });
        if (progress) gsap.set(progress, { scaleX: 0, transformOrigin: 'left center' });
        if (current) current.textContent = '00 / ' + String(words.length).padStart(2, '0');

        const timeline = gsap.timeline({ defaults: { ease: 'none' } });

        words.forEach(function (word, index) {
            const at = index * STEP;
            const text = texts[index];
            timeline
                .fromTo(word, {
                    '--sh-ghost': CONFIG.dim
                }, {
                    '--sh-ghost': 0,
                    duration: EDGE
                }, at)
                .to(word, {
                    y: -CONFIG.lift * 0.45,
                    duration: EDGE
                }, at)
                .to(text, {
                    autoAlpha: 1,
                    color: edge,
                    duration: EDGE
                }, at)
                .to(word, {
                    y: -CONFIG.lift,
                    duration: FLASH
                }, at + EDGE)
                .to(text, {
                    color: accent,
                    duration: FLASH
                }, at + EDGE)
                .to(word, {
                    y: 0,
                    duration: SETTLE
                }, at + EDGE + FLASH)
                .to(text, {
                    color: foreground,
                    duration: SETTLE
                }, at + EDGE + FLASH);
        });

        ScrollTrigger.create({
            trigger: trigger,
            animation: timeline,
            start: usesStageTrigger ? 'top top' : 'top 80%',
            end: usesStageTrigger ? 'bottom bottom' : 'bottom 65%',
            scrub: CONFIG.scrub,
            invalidateOnRefresh: true,
            onUpdate: function (self) {
                if (!container.isConnected) return;
                if (progress && progress.isConnected) {
                    gsap.set(progress, { scaleX: self.progress });
                }
                if (current && current.isConnected) {
                    const count = Math.min(words.length, Math.floor(self.progress * words.length));
                    current.textContent = String(count).padStart(2, '0')
                        + ' / ' + String(words.length).padStart(2, '0');
                }
            }
        });
    }

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

        mm.add({
            isMotion: '(prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function (context) {
            let active = true;

            if (context.conditions.isReduced) {
                document.querySelectorAll(BLOCKS).forEach(function (block) {
                    gsap.set(block, { opacity: 1, clearProps: 'transform' });
                });
                return function () { active = false; };
            }

            document.documentElement.classList.add('has-scroll-highlight');
            document.fonts.ready.then(function () {
                if (!active) return;
                document.querySelectorAll(BLOCKS).forEach(buildBlock);
                ScrollTrigger.refresh();
            });

            return function cleanup() {
                active = false;
                document.documentElement.classList.remove('has-scroll-highlight');
                ScrollTrigger.getAll().forEach(function (scrollTrigger) {
                    scrollTrigger.kill();
                });
                splits.forEach(function (split) {
                    split.revert();
                });
                splits.length = 0;
                unannounce();
            };
        });
    });

    window.gsapContext = ctx;

    function teardown() {
        if (ctx) ctx.revert();
        if (syncLenisOnRefresh) {
            ScrollTrigger.removeEventListener('refresh', syncLenisOnRefresh);
            syncLenisOnRefresh = null;
        }
        if (lenisTick) {
            gsap.ticker.remove(lenisTick);
            lenisTick = null;
        }
        if (lenis) {
            lenis.destroy();
            lenis = null;
        }
        window.removeEventListener('beforeunload', teardown);
    }

    window.addEventListener('beforeunload', teardown);
});
!function(t){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):t()}(function(){const t=".scroll-highlight";if("undefined"==typeof gsap||"undefined"==typeof ScrollTrigger||"undefined"==typeof SplitText)return;gsap.registerPlugin(ScrollTrigger,SplitText);const e="off"!==(new URLSearchParams(location.search).get("smooth")||document.documentElement.dataset.smooth)&&!window.matchMedia("(prefers-reduced-motion: reduce)").matches;let n=null,o=null,r=null;e&&"undefined"!=typeof Lenis&&(n=new Lenis({autoRaf:!1}),n.on("scroll",ScrollTrigger.update),o=function(t){n.raf(1e3*t)},r=function(){n&&n.scrollTo(window.scrollY,{immediate:!0,force:!0})},gsap.ticker.add(o),gsap.ticker.lagSmoothing(0),ScrollTrigger.addEventListener("refresh",r));const i=[],s=.16,a=.28;function c(t,e,n){const o=parseFloat(t.dataset[e]);return Number.isFinite(o)?o:n}function l(t,e){const n=document.createElement("span");n.style.cssText="position:absolute;visibility:hidden;color:"+e,t.appendChild(n);const o=getComputedStyle(n).color;return n.remove(),o}function d(t,e){if(!t)return null;try{return document.querySelector(t)||e.closest(t)}catch(t){return null}}function u(t){if(!t.isConnected)return;const e={dim:c(t,"highlightDim",.16),accent:"false"!==t.dataset.highlightAccent,lift:c(t,"highlightLift",7),scrub:void 0===t.dataset.highlightScrub||("true"===t.dataset.highlightScrub||c(t,"highlightScrub",!0))},n=d(t.dataset.highlightTrigger,t)||t,o=d(t.dataset.highlightProgress,t),r=d(t.dataset.highlightCurrent,t),u=new SplitText(t,{type:"words",wordsClass:"sh-word",aria:"none"});i.push(u),function(t){if(t.previousElementSibling&&t.previousElementSibling.hasAttribute("data-sh-sr"))return;const e=document.createElement("p");e.setAttribute("data-sh-sr",""),e.textContent=t.textContent.replace(/\s+/g," ").trim(),e.style.cssText="position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;",t.parentNode.insertBefore(e,t),t.setAttribute("aria-hidden","true")}(t);const h=u.words;if(!h.length)return;const g=h.map(function(t){const e=document.createElement("span");for(e.className="sh-word__text";t.firstChild;)e.appendChild(t.firstChild);return t.appendChild(e),t.setAttribute("data-mark",e.textContent),e}),f=getComputedStyle(t).color,p=e.accent?l(t,"var(--accent)"):f,m=e.accent?l(t,"var(--highlight-edge, #ff6b35)"):f,S=n!==t;t.classList.add("is-reading"),t.style.setProperty("--sh-dim",String(e.dim)),gsap.set(h,{y:e.lift}),gsap.set(g,{autoAlpha:0,color:f}),o&&gsap.set(o,{scaleX:0,transformOrigin:"left center"}),r&&(r.textContent="00 / "+String(h.length).padStart(2,"0"));const b=gsap.timeline({defaults:{ease:"none"}});h.forEach(function(t,n){const o=1*n,r=g[n];b.fromTo(t,{"--sh-ghost":e.dim},{"--sh-ghost":0,duration:s},o).to(t,{y:.45*-e.lift,duration:s},o).to(r,{autoAlpha:1,color:m,duration:s},o).to(t,{y:-e.lift,duration:a},o+s).to(r,{color:p,duration:a},o+s).to(t,{y:0,duration:.72},o+s+a).to(r,{color:f,duration:.72},o+s+a)}),ScrollTrigger.create({trigger:n,animation:b,start:S?"top top":"top 80%",end:S?"bottom bottom":"bottom 65%",scrub:e.scrub,invalidateOnRefresh:!0,onUpdate:function(e){if(t.isConnected&&(o&&o.isConnected&&gsap.set(o,{scaleX:e.progress}),r&&r.isConnected)){const t=Math.min(h.length,Math.floor(e.progress*h.length));r.textContent=String(t).padStart(2,"0")+" / "+String(h.length).padStart(2,"0")}}})}const h=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(e){let n=!0;return e.conditions.isReduced?(document.querySelectorAll(t).forEach(function(t){gsap.set(t,{opacity:1,clearProps:"transform"})}),function(){n=!1}):(document.documentElement.classList.add("has-scroll-highlight"),document.fonts.ready.then(function(){n&&(document.querySelectorAll(t).forEach(u),ScrollTrigger.refresh())}),function(){n=!1,document.documentElement.classList.remove("has-scroll-highlight"),ScrollTrigger.getAll().forEach(function(t){t.kill()}),i.forEach(function(t){t.revert()}),i.length=0,document.querySelectorAll("[data-sh-sr]").forEach(function(t){const e=t.nextElementSibling;e&&e.removeAttribute("aria-hidden"),t.remove()})})})});window.gsapContext=h,window.addEventListener("beforeunload",function t(){h&&h.revert(),r&&(ScrollTrigger.removeEventListener("refresh",r),r=null),o&&(gsap.ticker.remove(o),o=null),n&&(n.destroy(),n=null),window.removeEventListener("beforeunload",t)})});
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    /* A page you read, because that is what a reading highlight is for. The
       word under the reader takes the accent; the word just behind it takes
       the softer second colour, so the eye can see the sequence move. */
    --page: #f4f2ec;
    --surface: #ffffff;
    --text: #17171a;
    --muted: rgba(23, 23, 26, 0.55);
    --lime: #2f5bd7;    /* names kept so the script's data attributes still read */
    --orange: #b8532a;
    --accent: var(--lime);
    --highlight-edge: var(--orange);
    color-scheme: light;
}

html {
    scroll-behavior: auto;
    background: var(--page);
}

body {
    min-width: 320px;
    background: var(--page);
    color: var(--text);
    font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    overflow-x: clip;
    -webkit-font-smoothing: antialiased;
}

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

::-webkit-scrollbar-thumb {
    background: #30362a;
    border: 2px solid var(--black);
    border-radius: 99px;
}

.reading-sequence {
    min-height: 100svh;
}

.has-scroll-highlight .reading-sequence {
    height: 225svh;
}

.reading-stage {
    position: sticky;
    top: 0;
    display: grid;
    grid-template-rows: auto 1fr auto;
    width: 100%;
    height: 100svh;
    min-height: 34rem;
    padding: clamp(2rem, 5vh, 3.75rem) clamp(1.25rem, 5vw, 5.5rem);
    overflow: hidden;
    isolation: isolate;
}

.stage-meta,
.reading-coordinate {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    color: var(--muted);
    font-size: clamp(0.75rem, 0.92vw, 0.85rem);
    font-weight: 400;
}

.scroll-cue {
    display: flex;
    align-items: center;
    gap: 0.65rem;
    color: var(--text);
}

.scroll-cue span {
    width: 0.48rem;
    height: 0.48rem;
    background: var(--accent);
    border-radius: 50%;
}

.manifesto {
    align-self: center;
    width: calc(100% - clamp(1rem, 6vw, 7rem));
    min-width: 0;
    max-width: 15.5ch;
    margin-left: clamp(1rem, 6vw, 7rem);
    color: var(--text);
    /* Cap display type by viewport height as well as width so the complete
       statement still fits inside short, wide product iframes. */
    font-size: clamp(2.9rem, min(6.25vw, 8vh), 6.35rem);
    font-weight: 600;
    line-height: 1.0;
    letter-spacing: -0.035em;
    text-wrap: balance;
}

.sh-word {
    position: relative;
    display: inline-block;
    will-change: transform;
}

/* An unread word shows a ghost preview and hides its real text. The ghost is
   generated content painted from data-mark, so there is only ever one text
   node per word; the real text sits in the same box and becomes visible at
   full ink as the reading front arrives. Both rules are gated on the class
   the script adds, so without JavaScript the statement reads plainly. */
.has-scroll-highlight .sh-word[data-mark]::before {
    content: attr(data-mark);
    position: absolute;
    top: 0;
    left: 0;
    color: var(--text);
    opacity: var(--sh-ghost, var(--sh-dim, 0.16));
    white-space: pre;
    pointer-events: none;
}

.has-scroll-highlight .sh-word__text {
    visibility: hidden;
    will-change: opacity, color;
}

.reading-coordinate {
    display: grid;
    grid-template-columns: auto minmax(6rem, 1fr) auto;
    width: 100%;
    min-width: 0;
    padding-left: clamp(1rem, 6vw, 7rem);
}

.progress-track {
    position: relative;
    height: 1px;
    overflow: hidden;
    background: rgba(23, 23, 26, 0.18);
}

.progress-fill {
    position: absolute;
    inset: -1px 0;
    background: linear-gradient(90deg, var(--lime) 0 calc(100% - 0.65rem), var(--orange) 100%);
    transform: scaleX(1);
    transform-origin: left center;
}

.coordinate-count {
    min-width: 4.8rem;
    color: var(--text);
    text-align: right;
}

/* Build-only frozen reading-front state used by thumbnail.html. */
.thumbnail-page {
    overflow: hidden;
}

.thumbnail-page .reading-sequence {
    height: 100vh;
}

.thumbnail-page .reading-stage {
    height: 100vh;
}

.thumbnail-page .sh-word.is-complete {
    color: var(--text);
    opacity: 1;
}

.thumbnail-page .sh-word.is-edge {
    color: var(--orange);
    opacity: 1;
    transform: translateY(-0.18em);
}

.thumbnail-page .sh-word.is-active {
    color: var(--lime);
    opacity: 1;
    transform: translateY(-0.28em);
}

.thumbnail-page .sh-word.is-unread {
    opacity: 0.22;
}

.thumbnail-page .progress-fill {
    transform: scaleX(0.48);
}

@media (max-width: 700px) {
    .has-scroll-highlight .reading-sequence {
        height: 185svh;
    }

    .reading-stage {
        min-height: 30rem;
        padding: 1.8rem 1.1rem 2rem;
    }

    .coordinate-label {
        display: none;
    }

    .manifesto {
        width: calc(100% - 0.85rem);
        max-width: 14ch;
        margin-left: 0.85rem;
        font-size: clamp(1.85rem, min(9.8vw, 5.6vh), 3.35rem);
        line-height: 0.98;
        letter-spacing: -0.048em;
        text-wrap: pretty;
    }

    .reading-coordinate {
        grid-template-columns: minmax(0, 1fr) auto;
        padding-left: 0.85rem;
    }
}

@media (max-height: 620px) and (min-width: 701px) {
    .reading-stage {
        min-height: 0;
    }

    .manifesto {
        font-size: clamp(2.5rem, 7.5vh, 4.6rem);
    }
}

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

    .reading-sequence {
        height: 100svh;
    }

    .scroll-highlight,
    .sh-word,
    .sh-word__text {
        color: var(--text) !important;
        opacity: 1 !important;
        visibility: visible !important;
        transform: none !important;
    }

    .sh-word::before {
        content: none !important;
    }

    .progress-fill {
        transform: scaleX(1) !important;
    }
}

A reversible, scroll-linked reading front for editorial copy. SplitText breaks a statement into words; unread words stay ghosted, the active word lifts through a brief orange-to-lime flash, and completed words settle to calm ink. Optional progress and coordinate elements can follow the same scrubbed ScrollTrigger.

Each word holds two layers in the same box. The ghost preview is generated content, ::before { content: attr(data-mark) } on the word, painted at the dim opacity. The real text lives in an inner .sh-word__text span that stays visibility: hidden until the reading front arrives, then crossfades in at full ink as the ghost fades out. So an unread word is previewed rather than dimmed: nothing a reader is asked to read is ever painted below the contrast floor, and the ghost is decoration with no text node behind it.

Quick Start

1. Add to your HTML <head>:

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

2. Add the statement to your <body>:

<p class="scroll-highlight">
  Attention is chosen. Read deliberately, one word at a time.
</p>

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

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

GSAP and SplitText are free. Lenis is optional; load https://unpkg.com/lenis@1.3.17/dist/lenis.min.js before script.js for the smooth-scroll integration used by the demo.

Options

Set these data attributes on .scroll-highlight. The original data-attribute API remains supported.

Attribute Values Default Description
data-highlight-dim 0 to 1 0.16 Opacity of the ghost preview an unread word shows. It is not the opacity of readable text: the real text is hidden until it is read, so this can go as faint as the design wants
data-highlight-accent true, false true Enables the orange/lime active-word front before white settle
data-highlight-lift Number in px 7 Distance the active word lifts
data-highlight-scrub true or seconds true Direct scrub, or a numeric catch-up duration
data-highlight-trigger CSS selector Current block Uses another element as the scroll range; ideal for a sticky stage
data-highlight-progress CSS selector None Element whose horizontal scale displays reading progress
data-highlight-current CSS selector None Element updated with the completed/total word coordinate

Set the two front colours with CSS custom properties:

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

Examples

Standalone editorial copy

Add to your HTML <body>:

<p class="scroll-highlight" data-highlight-dim="0.2" data-highlight-lift="5">
  Some ideas only arrive when every word receives its proper time.
</p>

The block itself supplies the trigger range, matching the original drop-in behaviour.

Sticky stage with a progress rail

Add to your HTML <body>:

<section class="reading-sequence" data-reading-sequence>
  <div class="reading-stage">
    <p class="scroll-highlight"
       data-highlight-trigger="[data-reading-sequence]"
       data-highlight-progress="[data-reading-progress]"
       data-highlight-current="[data-reading-current]">
      Attention is chosen. Read deliberately, one word at a time.
    </p>
    <span class="progress-fill" data-reading-progress></span>
    <span data-reading-current>10 / 10</span>
  </div>
</section>

The external trigger uses top top to bottom bottom, allowing the text stage to remain sticky while one timeline drives words, rail, and coordinate.

White-only reading front

Add to your HTML <body>:

<p class="scroll-highlight" data-highlight-accent="false" data-highlight-scrub="0.5">
  The same reversible opacity and lift, without an accent flash.
</p>

CSS Classes

Class Description
.scroll-highlight Reusable block selected by the script
.sh-word Word element generated by SplitText. Carries data-mark (the word) and paints the ghost from it in ::before
.sh-word__text Inner span holding the real text; visibility: hidden until the word is read
.is-reading Added after a block has been split and initialised
.has-scroll-highlight Added to <html> while the effect runs; both the ghost and the hidden text are gated on it

The ghost opacity rides a --sh-ghost custom property on each word, with --sh-dim on the block as its fallback (GSAP removes an inline custom property whenever it reverts a tween, and every ScrollTrigger refresh does).

Accessibility

  • Reduced-motion readers receive the complete statement at full opacity with no split animation or extended scroll runway.
  • Without JavaScript, or when a CDN is blocked, the original unsplit statement and complete progress rail remain visible.
  • The effect follows native scroll position, so keyboard, wheel, trackpad, and touch scrolling all operate it.
  • Text remains real, selectable text; SplitText reverts its generated spans during cleanup.
  • The unread state hides the real text rather than dimming it, so no reader-facing text is ever painted under the contrast floor. The faint preview is generated content from data-mark, which is decoration by definition: it has no text node, so nothing is announced and nothing is measured. Full ink arrives with the reading front.
  • The split words are hidden from assistive technology and the sentence is announced once from a visually hidden sibling paragraph, so the statement is read as a sentence rather than as twenty-six fragments. SplitText's own aria handling is switched off because its aria-label is invalid on a <p> with no role.
  • Without the script, and under reduced motion, no ghost is painted and no text is hidden: both rules are scoped to the has-scroll-highlight class the script adds.
  • The demo scores 100 on Lighthouse accessibility with the ghost at 0.3.

Performance and Cleanup

Each block uses one SplitText instance, one timeline, and one ScrollTrigger. Initialisation waits for document.fonts.ready so line wrapping is measured against the loaded display font. Cleanup kills ScrollTriggers, reverts every split, removes the GSAP ticker callback, destroys Lenis, and removes the named Lenis refresh listener.

Dependencies

Required:

  • GSAP 3.12+
  • ScrollTrigger
  • SplitText

Optional:

  • Lenis 1.3+