Theme inversion on scroll
The moment a site alternates light and dark sections, every fixed element on top of them — nav, cursor, progress bar, back-to-top — has to invert or it disappears against half the page. The tempting fix is to tween colours, and it is the wrong one: you end up with a tween per element per section and a colour value duplicated in six places. The right shape is a state machine with one input. Each section declares data-theme, one ScrollTrigger per section writes that value onto a wrapper as the section crosses the middle of the screen, and CSS variables cascade it to everything. Adding a seventh element that needs to invert is then a CSS line, not a JavaScript change. Two details make it feel solid: the trigger band is the middle of the viewport, and the transitions go on real properties, never on the variables.
How to build
Panel + script
Free plan gets you part of the way
The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.
How the two routes work →Plugins
ScrollTrigger
Build time
40 min for the system. Two minutes per element after that.
No-code?
The panel can toggle a class per section, so a two-state version is buildable. What it cannot give you is a single source of truth — you would be wiring each element's colour change by hand, which is exactly the problem this solves.
Webflow structure
element · class · attribute<b>Body</b> <i>page_wrap</i> <u>data-theme="light"</u> ← the script writes here <b>Navbar</b> <i>nav</i> ← position: fixed / sticky, colour from var(--fg) <b>Text</b> <i>nav_logo</i> <b>Div</b> <i>nav_badge</i> ← border + dot, both from var(--fg) <b>Section</b> <i>section</i> <u>data-theme="light" data-label="Issue 12"</u> <b>Section</b> <i>section</i> <u>data-theme="dark" data-label="Portfolio 01"</u> <b>Section</b> <i>section</i> <u>data-theme="light" data-label="Essay"</u> …
Styles to set in the Designer
page_wrap | Declare the light palette here: <code>--fg</code>, <code>--bg</code>, <code>--dim</code>, <code>--line</code>. This is the one place any colour is written down. page_wrap[data-theme="dark"] | Redefine <strong>only the same four variables</strong>. Nothing else in the stylesheet mentions the dark theme at all — that is the whole point of the pattern. nav / nav_badge | Use <code>color: var(--fg)</code>, <code>border-color: var(--fg)</code>, and put the transition on <strong>those</strong> properties: <code>transition: color .55s, border-color .55s</code>. Custom properties do not transition without <code>@property</code>, but the properties that consume them do. section | Each section paints its own background and text colour from its own <code>data-theme</code>. The wrapper variable is only for the things floating <em>above</em> the sections. sections generally | They must tile edge to edge with no gaps. A margin between two sections is a band where neither trigger is active and the theme is left on whatever it was.
Building it with the Interactions panel
no code, free plan- Select each section → Interactions → trigger Scroll into view, anchored to where it should invert → Add Class (e.g. is-dark) on the nav or wrapper, Remove Class on scroll back out.
- Style the inverted state as a Combo Class (nav.is-dark, etc.) with its own colours, and give the base classes a CSS transition so the swap animates smoothly instead of snapping.
What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
- This wires each element's colour change by hand, per section, per element — exactly the problem the CSS-variable state machine solves. Add an eighth element that needs to invert and you're editing every section's interaction, not one CSS line.
- No single source of truth — two interactions can disagree about which section is "active" near a boundary, where the attribute-driven version guarantees exactly one data-theme value at a time.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesEvery section | data-theme | light / dark | The only thing an editor ever has to set. Adding a section is one dropdown, not an interaction. Every section | data-label | Portfolio 01 | Optional. Lets the nav badge name the section you are in — nearly free once the machinery exists. Wrapper | data-theme | (written by JS) | Set the initial value in the Designer to match your first section, so the nav is correct before the script runs.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Site settings → Footer code, so it runs on every page --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const wrap = document.querySelector(".page_wrap"); const nav = document.querySelector(".nav"); const badge = document.querySelector(".nav_badge span"); gsap.utils.toArray(".section").forEach(section => { ScrollTrigger.create({ trigger: section, // Anchor the band to whatever actually inverts. For a nav pinned to // the top that is the NAV'S OWN BOTTOM EDGE — not 50%. Sections tile // edge to edge, so exactly one is ever active. start: () => "top " + nav.offsetHeight, end: () => "bottom " + nav.offsetHeight, invalidateOnRefresh: true, onToggle: self => { if (!self.isActive) return; // ignore the one leaving wrap.dataset.theme = section.dataset.theme || "light"; if (badge) badge.textContent = section.dataset.label || ""; } }); }); }); </script> <!-- Page settings → Inside <head> tag --> <style> .page_wrap { --fg: #14140f; --bg: #f7f5f1; --dim: rgba(20,20,15,.55); --line: rgba(20,20,15,.18); } .page_wrap[data-theme="dark"] { --fg: #f2f0ec; --bg: #0f1011; --dim: rgba(242,240,236,.55); --line: rgba(242,240,236,.2); } /* Transition the PROPERTY, not the variable. */ .nav { color: var(--fg); transition: color .55s ease; } .nav_badge { border-color: var(--fg); transition: border-color .55s ease; } .nav_badge i{ background: var(--fg); transition: background .55s ease; } </style>
Gotchas that will cost you an hour
- Sections must tile with no gaps. A margin between two sections leaves a scroll band where neither trigger is active, so the theme sticks on the previous value and the nav is briefly invisible. Use padding inside sections, never margin between them.
- Transition the property, not the variable. transition: --fg .5s does nothing without an @property declaration. transition: color .5s on an element that reads var(--fg) works everywhere, today.
- Guard with if (!self.isActive) return. onToggle fires for the section leaving as well as the one arriving, and without the guard the outgoing section gets the last word — so scrolling up leaves you one theme behind.
- Set the starting data-theme in the Designer. Between first paint and DOMContentLoaded there is no attribute, so the nav renders in the default palette and visibly flips. One attribute removes it.
- Anchor the band to the element that inverts, not to 50%. A nav pinned to the top must switch as the boundary passes under the nav — start: "top " + nav.offsetHeight. Use 50% and the nav spends half a second wearing the next section's colours while still sitting over this one, which is exactly when it is unreadable. For a centred element like a cursor, 50% is the right anchor. The rule is: measure to whatever is doing the inverting.
- mix-blend-mode: difference is the other answer, and it is not always better. It needs no JavaScript at all, but it gives you no control over the colour, breaks over images, and fails behind any backdrop-filter. Use blend mode for a cursor, this system for a nav.
Push it further
- Feed the same wrapper attribute into the cursor system so the custom cursor inverts through the identical mechanism instead of relying on blend mode.
- Add a third theme — data-theme="accent" — for a single call-to-action section. It costs four variables and no JavaScript at all.
- Sync the browser chrome: update <meta name="theme-color"> in the same onToggle, and the phone's status bar changes with the page.
Webflow Motion Lab — GSAP 3.15, all plugins free.
21
/ 30