049

Scroll Colour Shift

£10 or the whole Vault, £99

A scroll-linked palette system: each section declares its colours and the page background, text, rules and accents transition together as you scroll, with text contrast guaranteed at every point of the blend.

ScrollTrigger Lenis advanced
5 more details
scroll-linked-colortoken-themingcontrast-guardscrubtheme-switch

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

Updated

About this effect

A scroll-driven colour journey for long pages. Each section carries three data attributes for its background, ink and accent, and a single ScrollTrigger interpolates between neighbouring palettes in Oklab as the boundary crosses the viewport, writing the result into CSS custom properties that the whole page reads.

Read the full effect overview

The hard part is the middle of the transition. Cross-fading a light section into a dark one moves the background and the text toward each other, and halfway across they meet as grey on grey. This effect measures the WCAG contrast ratio on every frame and corrects the ink's lightness until the ratio holds, so the type stays readable at the exact moment a naive version disappears.

The demo ships three complete sequences over the same page: a neutral studio journey, a saturated poster journey, and a mostly-restrained one that detonates twice. Switching between them is itself an interpolation, so you can watch every section migrate at once.

Scroll Colour Shift - GSAP animation effect preview

What's included

12 items
  • Declarative per-section palettes: data-color-bg, data-color-ink and data-color-accent, with ink and accent optional
  • Seven CSS custom properties written live, covering background, ink, secondary ink, accent, on-accent label, rules and surfaces
  • Contrast guard holds a configurable minimum ratio (default 4.5:1) at every frame, including the midpoint of a light-to-dark crossing
  • Oklab interpolation keeps the midpoint of two saturated colours on the line a designer would draw, instead of dragging it through grey
  • Adjustable reference line so a tall sticky header always shows the palette of whatever sits directly beneath it
  • Position-derived state: flick-scrolling, hash jumps and reverse scrolling all land on exactly the right colour
  • Optional live contrast readout and palette position markers driven from the same state
  • Native scrollbars and form controls follow the ground automatically through color-scheme
  • Reduced motion keeps every section's colours correct and drops only the blend
  • Lenis integration on the shared GSAP ticker, with a full teardown of tickers, listeners and ScrollTriggers
  • Named palette sequences: declare several journeys on the same page and switch between them at runtime, the whole page migrating in Oklab rather than cutting
  • Switcher API and event: `setSequence()` on the container, `[data-color-set-control]` buttons, and a bubbling `colorshift:sequence` event

Perfect for

6 use cases
  • Long-form landing pages that need a designed colour journey rather than one flat ground
  • Product stories where each chapter owns a mood and the chrome has to follow it
  • Brand and studio sites moving between light and dark sections without a jarring cut
  • Editorial features and scrollytelling pieces with per-chapter colourways
  • Documentation-heavy pages where an accessible minimum contrast is non-negotiable
  • Design system demos showing tokens re-theming a page in real time

How it works

3 sections

Every element carrying data-color-palette is read once at init and its declared colours are converted into Oklab. A single ScrollTrigger spanning the container calls one render function on update, which derives the colour state purely from the scroll position: it finds which boundary is currently inside the transition band, computes a smooth mix of the two neighbouring palettes, and derives the seven tokens from that mix.

Each text-bearing token then passes through a contrast guard. It converts the candidate ink and background to WCAG relative luminance, and when the ratio falls short it binary-searches the ink's Oklab lightness toward whichever pole can reach the target, changing lightness only as far as needed and leaving hue and chroma alone. The tokens are written with setProperty on the document element, so the background, headings, body copy, rules, buttons and the sticky header all move together.

Under prefers-reduced-motion the transition band collapses to zero, which turns the same render function into a per-section step change, and cleanup kills the ScrollTrigger, removes the Lenis ticker and refresh listener, and clears the color-scheme override.

Plugins ScrollTrigger
Difficulty Advanced
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.

Paid effect

Purchase to unlock the code.

Buying Scroll Colour Shift opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse free effects

Or the whole library: the Vault, £99, one payment.

Documentation

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

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

Code snippet omitted: it ships with the download.

3. Add to your <body>:

Code snippet omitted: it ships with the download.

4. Point your CSS at the tokens the effect writes:

Code snippet omitted: it ships with the download.

That is the whole integration. Nothing animates the sections themselves; the colour lives entirely in the tokens.

Using It With Your Own Design

What the effect requires of your markup:

  • One container with data-color-shift, containing at least two [data-color-palette] elements. They do not have to be <section> elements and they can be any height.
  • data-color-bg on each palette element. data-color-ink and data-color-accent are optional: with only a background declared, the contrast guard supplies a legible ink for you, and the accent falls back to the ink.
  • Colours can be written in any form the browser understands: hex, rgb(), hsl(), oklch(), or a keyword. They are resolved once at init.

What your CSS has to do: read the tokens. Any colour that should participate in the journey must be var(--cs-…) rather than a literal. Anything left as a literal simply stays put, which is a legitimate choice for a logo or a photograph.

Non-obvious CSS the effect depends on:

  • The tokens are written to <html> by default, so html { background: var(--cs-bg) } matters as much as body does. Without it, an overscroll bounce shows the browser's white.
  • Give :root a full set of --cs-* defaults, as style.css does. They are what the page renders in before the script runs and if JavaScript never runs at all, so make them a real palette rather than placeholders.
  • A sticky or fixed header should use color-mix(in srgb, var(--cs-bg) 82%, transparent) rather than a solid literal. It then re-tunes with the ground beneath it instead of becoming an opaque slab of the wrong colour mid-transition.
  • Do not put a CSS transition on the properties the tokens feed. The scroll position already drives them continuously; a transition on top adds lag that reads as the colour trailing the page.

What is only the demo's CSS and can be deleted: everything in style.css below the header block. The panels, rows, cards, pull quote and rail are one paint maker's landing page, not part of the effect.

Options

Set on the [data-color-shift] container:

Attribute Values Default Description
data-color-shift (presence) required Marks the container that owns the palette journey
data-color-min-contrast number 4.5 Minimum WCAG contrast ratio held between --cs-ink and --cs-bg at every frame. 7 for AAA body copy; 3 if you want the declared inks respected more literally
data-color-line 0 to 1 0.5 Where in the viewport a boundary counts as arrived, as a fraction of viewport height. Lower it toward 0.2 when a tall sticky header sits over the page
data-color-band number 0.42 Length of the transition, as a fraction of viewport height. 0 gives an instant cut at the reference line; 1 spreads the blend across a full screen of scrolling
data-color-scope root | self root Whether the tokens are written to <html> or only to this container. Use self for a themed block inside an otherwise fixed page
data-color-sets comma-separated names one unnamed set Names the palette sequences the page can switch between, in order. The first name is the base data-color-bg/ink/accent attributes, so a page with a single palette journey never sets this

Set on each [data-color-palette] element:

Attribute Values Default Description
data-color-palette (presence) required Marks a section that owns a palette
data-color-bg any CSS colour white The section's ground
data-color-ink any CSS colour derived Primary text colour. Omit it and a legible one is derived from the background
data-color-accent any CSS colour the ink Links, eyebrows, filled buttons, markers
data-color-set-<name> bg, ink, accent the base trio This section's colours in a named alternate sequence. Same three values in the same order, comma separated. Omit it on a section that should keep its base colours in that sequence

Optional hooks, placed anywhere inside the container:

Attribute Description
data-color-contrast Element whose text is set to the live ink/background ratio, e.g. 8.4:1. Also receives data-level="aaa" | "aa" | "low" for styling
data-color-marker One element per palette, in order. The one matching the current palette gets .is-active
data-color-set-control A <button> whose value is a sequence name. Clicking it switches the page to that sequence; the active one carries aria-pressed="true"

Tokens Written

Token What it is
--cs-bg The interpolated ground
--cs-ink Primary text, guaranteed to clear data-color-min-contrast against --cs-bg
--cs-ink-soft Secondary text, held at 3:1 so hierarchy survives the guard
--cs-accent The accent, nudged in lightness if it falls under 3:1 against the ground
--cs-accent-text The accent pushed to 4.6:1, for the accent set as small text (eyebrows, row numbers)
--cs-accent-ink Label colour for anything filled with --cs-accent
--cs-line The ink at 20% alpha, for rules and hairline borders
--cs-surface The ground moved 7% toward the ink, for cards and wells

How It Works

Each palette element's colours are converted to Oklab once at init. A single ScrollTrigger spanning the container calls one render function on update, and that function derives everything from the scroll position: which boundary is currently inside the transition band, how far across it the page is, and therefore what mix of the two neighbouring palettes applies. Because the state is a function of position rather than an accumulation of triggers firing, a flick scroll, a hash jump or scrolling backwards all land on exactly the right colour.

Oklab does the interpolation because sRGB does not. Mixing two saturated colours channel by channel drags the midpoint through a desaturated sludge; in Oklab the midpoint sits where a designer would have put it.

The contrast guard is the part that makes this safe to ship. Cross-fading a light section into a dark one moves the background down and the ink up, and halfway across they meet: grey on grey, for as long as the transition lasts. So the ink is not trusted. Every frame, the candidate ink and background are converted to WCAG relative luminance; if the ratio falls short of the configured minimum, the ink's Oklab lightness is binary-searched toward whichever pole (black or white) can actually reach the target, moving only as far as it needs to and leaving hue and chroma untouched. Contrast is continuous in lightness, so the correction eases in and out rather than snapping.

The same pass holds secondary text at 3:1, lifts a low-contrast accent off the ground, and picks black or white for labels sitting on top of the accent.

One honest caveat: when a light section meets a dark one the background necessarily passes through mid-tone, and at that exact point no continuous ink path holds the ratio, because both poles are equally far away. The ink therefore flips polarity in a single frame, at the moment the two directions are exactly equal in contrast. It is the only discontinuity in the system and it is the best available one: the ratio never drops below the floor on either side of it.

Accessibility

  • Reduced motion: gsap.matchMedia collapses the transition band to zero, so each section still gets its own correct palette and only the blend is dropped. Nothing is hidden and nothing is left mid-transition.
  • No JavaScript: the :root defaults in style.css render the page in the first section's palette. Every section stays legible; only the journey is absent.
  • Contrast: the guard enforces the configured minimum at every frame, not only at the endpoints. Set data-color-min-contrast="7" for AAA.
  • Native UI: color-scheme is set from the live background luminance, so scrollbars, form controls and focus rings follow the page instead of standing out against it.
  • Keyboard: the demo's links and buttons carry :focus-visible styles built from the same tokens, so focus is visible in every palette.

Performance Notes

  • One ScrollTrigger for the whole container, not one per section.
  • Token writes are skipped when the computed background is unchanged, so a stationary reader costs nothing.
  • The per-frame colour maths is a handful of cube roots plus at most three short binary searches: microseconds, and no layout is read during the update.
  • No element is transformed or repainted by the effect itself; only custom properties change.

Dependencies

  • GSAP 3.15.0
  • ScrollTrigger
  • Lenis 1.3.17 (optional, for smooth scroll)

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.