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
Scroll Text Highlight - GSAP animation effect preview

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

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.

What's included

8 items
  • Sharp orange-to-lime active-word front with a subtle vertical lift
  • Completed words settle to white while unread words remain ghosted
  • 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 and GSAP seeds the unread state without relying on CSS to hide content. A timeline moves every word through a brief orange edge, lime lift and white 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
<!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.
 * A sharp orange-to-lime front lifts the active word, then leaves completed
 * words calm and white. 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;
        }
    }

    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);
        const split = new SplitText(container, { type: 'words', wordsClass: 'sh-word' });
        splits.push(split);

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

        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');
        gsap.set(words, { opacity: CONFIG.dim, y: CONFIG.lift, 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;
            timeline
                .to(word, {
                    opacity: 1,
                    y: -CONFIG.lift * 0.45,
                    color: edge,
                    duration: EDGE
                }, at)
                .to(word, {
                    y: -CONFIG.lift,
                    color: accent,
                    duration: FLASH
                }, at + EDGE)
                .to(word, {
                    y: 0,
                    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;
            };
        });
    });

    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=[],c=.16;function s(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 a(t,e){if(!t)return null;try{return document.querySelector(t)||e.closest(t)}catch(t){return null}}function d(t){if(!t.isConnected)return;const e={dim:s(t,"highlightDim",.16),accent:"false"!==t.dataset.highlightAccent,lift:s(t,"highlightLift",7),scrub:void 0===t.dataset.highlightScrub||("true"===t.dataset.highlightScrub||s(t,"highlightScrub",!0))},n=a(t.dataset.highlightTrigger,t)||t,o=a(t.dataset.highlightProgress,t),r=a(t.dataset.highlightCurrent,t),d=new SplitText(t,{type:"words",wordsClass:"sh-word"});i.push(d);const u=d.words;if(!u.length)return;const g=getComputedStyle(t).color,h=e.accent?l(t,"var(--accent)"):g,f=e.accent?l(t,"var(--highlight-edge, #ff6b35)"):g,m=n!==t;t.classList.add("is-reading"),gsap.set(u,{opacity:e.dim,y:e.lift,color:g}),o&&gsap.set(o,{scaleX:0,transformOrigin:"left center"}),r&&(r.textContent="00 / "+String(u.length).padStart(2,"0"));const p=gsap.timeline({defaults:{ease:"none"}});u.forEach(function(t,n){const o=1*n;p.to(t,{opacity:1,y:.45*-e.lift,color:f,duration:c},o).to(t,{y:-e.lift,color:h,duration:.28},o+c).to(t,{y:0,color:g,duration:.72},o+c+.28)}),ScrollTrigger.create({trigger:n,animation:p,start:m?"top top":"top 80%",end:m?"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(u.length,Math.floor(e.progress*u.length));r.textContent=String(t).padStart(2,"0")+" / "+String(u.length).padStart(2,"0")}}})}const u=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(d),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})})});window.gsapContext=u,window.addEventListener("beforeunload",function t(){u&&u.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 {
    display: inline-block;
    will-change: opacity, color, transform;
}

.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 {
        color: var(--text) !important;
        opacity: 1 !important;
        transform: 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 white. Optional progress and coordinate elements can follow the same scrubbed ScrollTrigger.

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 unread words
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.1" 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 span generated by SplitText
.is-reading Added after a block has been split and initialised

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.

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+

Your cart

Your cart is empty

The Vault £99

Everything in the catalogue, plus everything we release next.