Scroll colour inversion
The whole page flips between light and dark as a marked section crosses the middle of the viewport.
Grade: ARuntimes: cssStatus: code-only
Documented, no live demo yet — the code below is complete.
Intent
Section boundaries in a long scroll can be hard to feel. Inverting the page — background, text, nav, cursor — makes the boundary unmissable, and doing it at the page level rather than per section means the fixed navigation inverts too, which is what sells it.
Implementation
The `rootMargin` collapses the observer root to a 1px line at the vertical centre, so entries fire exactly on crossing.
Dependencies: none
<section data-theme-section="dark">…</section>
<section data-theme-section="light">…</section>
<style>
:root {
--ink: #0a0a0a;
--bg: #f4f2ee;
color: var(--ink);
background-color: var(--bg);
transition:
color var(--dur-curtain, 1.1s) var(--ease-in-out-quart, cubic-bezier(0.76, 0, 0.24, 1)),
background-color var(--dur-curtain, 1.1s) var(--ease-in-out-quart, cubic-bezier(0.76, 0, 0.24, 1));
}
:root[data-theme="dark"] {
--ink: #f4f2ee;
--bg: #0a0a0a;
}
@media (prefers-reduced-motion: reduce) {
:root { transition: none; }
}
</style>
<script type="module">
// A 1px observation band at the vertical centre of the viewport.
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const theme = entry.target.dataset.themeSection;
document.documentElement.dataset.theme = theme;
}
},
{ rootMargin: "-50% 0px -50% 0px", threshold: 0 },
);
for (const section of document.querySelectorAll("[data-theme-section]")) io.observe(section);
</script>Use it when
- Long single-page sites with two or three distinct movements.
- To mark the shift from marketing narrative into detail or proof.
Avoid it when
- More than three times per page — it stops being punctuation and becomes a strobe.
- When the site has a user-selected theme, unless inversion is expressed relative to that theme rather than hard-coded.
Accessibility
- Verify contrast in both themes, including the accent, focus rings, and any media scrims.
- Under reduced motion, switch instantly rather than cross-fading — an animated full-page colour change is exactly the kind of large-area motion the preference exists for.
- The transition must not run on `:root` colour alone if any child hard-codes a colour; audit for literals.
Performance
- Transitioning `background-color` and `color` on the root is a paint, not a layout — acceptable once or twice per page, not per scroll frame.
- Use a single observer with a 1px band so the callback fires on crossing rather than continuously.
Knobs
- trigger line
- Where in the viewport the flip happens. Default: 50%
- duration
- Cross-fade length. Default: 1100ms
Composition
Conflicts with
None.
Instruction for Claude Code
Implement page-level scroll colour inversion. Define --ink and --bg on :root, apply them to color and background-color with a 1100ms ease-in-out-quart transition, and override both under :root[data-theme="dark"]. Use one IntersectionObserver with rootMargin "-50% 0px -50% 0px" so it fires when a [data-theme-section] element crosses the viewport midline, and set document.documentElement.dataset.theme from the section's value. Disable the transition under prefers-reduced-motion. Audit the page for hard-coded colour literals — every colour must come from a token or the inversion will be partial.