Scroll-driven colour themes with Tailwind CSS and GSAP

Matthew FrawleyAtanas DimitrovOctober 5th, 2026

A scroll-driven colour theme lets a page shift between palettes as sections enter the viewport. The components keep their Tailwind classes; JavaScript changes the CSS variables behind those classes. This tutorial builds that setup with three palettes, GSAP ScrollTrigger, and Oklab colour mixing.

Define palette roles, not individual component colours

Each palette has five roles: accent, light, mid, dark, and darkest. Add them to the CSS file that imports Tailwind. The --color-dynamic-* variables start with teal values, so the page has a useful colour before any JavaScript runs.

app/globals.css
@import 'tailwindcss'; @theme static { --color-black: #050505; --color-orange-accent: #e97449; --color-orange-light: #f0ddd4; --color-orange-mid: #c6917b; --color-orange-dark: #3a2d27; --color-orange-darkest: #1d1716; --color-teal-accent: #37d6c7; --color-teal-light: #bfcfcd; --color-teal-mid: #609a92; --color-teal-dark: #2b403e; --color-teal-darkest: #191a1a; --color-blue-accent: #5a62f2; --color-blue-light: #b8b9d6; --color-blue-mid: #7477a4; --color-blue-dark: #2b2c40; --color-blue-darkest: #16171d; --color-dynamic-accent: var(--color-teal-accent); --color-dynamic-light: var(--color-teal-light); --color-dynamic-mid: var(--color-teal-mid); --color-dynamic-dark: var(--color-teal-dark); --color-dynamic-darkest: var(--color-teal-darkest); --color-dynamic-contrast: var(--color-black); }

The --color-dynamic-* names create Tailwind colour utilities. For example, --color-dynamic-accent gives us text-dynamic-accent, bg-dynamic-accent, and border-dynamic-accent. Updating that one CSS variable changes every element using those utilities without changing its class name. The example uses @theme static so Tailwind keeps all three palettes in the generated CSS, including colours that JavaScript reads without using a matching utility class.

Turn one scroll value into a full palette

Use a number from 0 to 2: orange at 0, teal at 1, and blue at 2. Values between those points blend every role in Oklab. This avoids swapping colours abruptly at a section boundary.

Install the packages used by the example:

npm install @thi.ng/color gsap @gsap/react

The example reads the palette colours from CSS, then writes each mixed colour to a variable on the <html> element. That makes the new colour available throughout the page.

components/ScrollPalette.tsx
'use client' import { useGSAP } from '@gsap/react' import { contrast } from '@thi.ng/color/contrast' import { css as toCss } from '@thi.ng/color/css/css' import { parseCss } from '@thi.ng/color/css/parse-css' import { mix as mixColors } from '@thi.ng/color/mix' import { Oklab, oklab } from '@thi.ng/color/oklab/oklab' import { rgb } from '@thi.ng/color/rgb/rgb' import gsap from 'gsap' import ScrollTrigger from 'gsap/dist/ScrollTrigger' import { type FC, useLayoutEffect } from 'react' gsap.registerPlugin(useGSAP, ScrollTrigger) type PaletteName = 'orange' | 'teal' | 'blue' type ColourRole = 'accent' | 'light' | 'mid' | 'dark' | 'darkest' type Palette = Record<ColourRole, Oklab> const roles: ColourRole[] = ['accent', 'light', 'mid', 'dark', 'darkest'] const BLACK = '#050505' const WHITE = '#ffffff' let cachedPalettes: Record<PaletteName, Palette> | null = null const getPalettes = (): Record<PaletteName, Palette> => { if (cachedPalettes) return cachedPalettes const styles = getComputedStyle(document.documentElement) const readPalette = (name: PaletteName): Palette => { const entries = {} as Palette for (const role of roles) { const value = styles.getPropertyValue(`--color-${name}-${role}`).trim() if (!value) throw new Error(`Missing colour: --color-${name}-${role}`) entries[role] = oklab(rgb(parseCss(value))) } return entries } cachedPalettes = { orange: readPalette('orange'), teal: readPalette('teal'), blue: readPalette('blue'), } return cachedPalettes } const setPaletteMix = (value: number): void => { const mix = Math.min(2, Math.max(0, value)) const palettes = getPalettes() const from = mix <= 1 ? palettes.orange : palettes.teal const to = mix <= 1 ? palettes.teal : palettes.blue const ratio = mix <= 1 ? mix : mix - 1 const root = document.documentElement for (const role of roles) { const colour = mixColors(from[role].copy(), from[role], to[role], ratio) root.style.setProperty(`--color-dynamic-${role}`, toCss(colour)) } const accent = mixColors(from.accent.copy(), from.accent, to.accent, ratio) const contrastColour = contrast(accent, BLACK) >= contrast(accent, WHITE) ? BLACK : WHITE root.style.setProperty('--color-dynamic-contrast', contrastColour) } export const ScrollPalette: FC = () => { useLayoutEffect(() => { setPaletteMix(1) return () => setPaletteMix(1) }, []) useGSAP(() => { const transitions = [ { trigger: '#problem', getMix: (progress: number) => 1 - progress }, { trigger: '#solution', getMix: (progress: number) => progress }, { trigger: '#work', getMix: (progress: number) => 1 + progress }, ] transitions.forEach(({ trigger, getMix }) => { ScrollTrigger.create({ trigger, start: 'top bottom', end: 'top center', scrub: true, onUpdate: (self) => setPaletteMix(getMix(self.progress)), }) }) }) return null }

contrast() compares BLACK and WHITE against the current accent colour. The better contrasting value goes into --color-dynamic-contrast, ready for text placed on an accent background.

Let section progress drive the change

ScrollTrigger reports a section's progress from 0 to 1 between start: 'top bottom' and end: 'top center'. In the example above, the problem section maps that progress from teal (1) to orange (0). The solution section takes it back to teal. The work section then moves from teal to blue.

Place ScrollPalette on the page containing those section IDs. useGSAP removes the scroll triggers when the component unmounts, and the layout effect restores teal when the page is entered or left.

Use the colours with ordinary Tailwind classes

Here is a minimal page using the scroll component and its three triggers. The heading, text, border, and button all respond when the CSS variables change.

app/page.tsx
import { ScrollPalette } from '@/components/ScrollPalette' export default function Home() { return ( <main className="bg-black text-white"> <ScrollPalette /> <section className="flex min-h-screen flex-col justify-center gap-6 p-10"> <h1 className="text-dynamic-accent text-5xl font-bold">Colour that follows the page</h1> <p className="text-dynamic-light">Scroll to see the palette change.</p> </section> <section id="problem" className="flex min-h-screen items-center p-10"> <p className="border-dynamic-accent text-dynamic-light border-l-4 pl-6">Teal becomes orange.</p> </section> <section id="solution" className="flex min-h-screen items-center p-10"> <p className="text-dynamic-accent text-3xl font-bold">Back to teal.</p> </section> <section id="work" className="flex min-h-screen items-center p-10"> <a href="#problem" className="bg-dynamic-accent text-dynamic-contrast rounded-lg px-5 py-3 font-semibold"> Blue accent, readable text </a> </section> </main> ) }

Notice that the button uses both bg-dynamic-accent and text-dynamic-contrast. The background and its readable text colour move together.

To make the theme your own, replace the palette colours, choose the sections that should trigger a change, and adjust their getMix functions. The Tailwind classes on your components can stay the same.

On our site, the same palette value also reaches the background shader, so the HTML and 3D scene change together. You can try the finished effect by scrolling the Loopspeed homepage.


Thanks for reading, Loopspeed ✌️

Looking for an experienced team to help bring a project to life?

Meet Loopspeed
tech icontech icontech icontech icontech icontech icontech icontech icon