Rejouice — loading0

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

html-css-js
<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

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.