Skip to content
Back home

Tutorial — Free

Scroll-driven scene moods with IntersectionObserver

The technique behind this site’s background: each section sets a mood, and the scene eases smoothly between them as you scroll, without a single React re-render.
Reading time
20 min
Stack
JavaScript · React · IntersectionObserver
Published
3 November 2026

Scroll down the home page of this site and the background quietly changes: the light shifts colour, the focus pulls in and out, and the glow brightens near the contact section. Every section has its own mood, and the scene glides between them as you scroll.

It looks like a lot of animation code. It isn't. The whole system is three small ideas:

  1. Sections declare their mood with a data attribute.
  2. An IntersectionObserver works out which section is in the middle of the screen.
  3. A render loop eases the current values towards that section's mood, every frame.

This tutorial is completely free. We'll build it in plain JavaScript first, so you can use it anywhere, then look at the React version running on this site.

The demo we're building

A full-screen backdrop behind the page, driven by three values:

  • focus: where the glow sits vertically
  • glow: how bright it is
  • tint: its colour

Each section sets different targets for those values, and the backdrop eases between them.

Step 1: sections declare their mood

Mark up each section with a data-section attribute. The page content doesn't need to know anything about the backdrop:

<div class="backdrop" aria-hidden="true"></div>

<main>
  <section data-section="intro">...</section>
  <section data-section="work">...</section>
  <section data-section="contact">...</section>
</main>

The backdrop is decorative, so aria-hidden="true" keeps it out of the accessibility tree.

Then describe each mood as data:

const MOODS = {
  default: { focus: 3, glow: 0.35, tint: [217, 164, 106] },
  intro: { focus: 3, glow: 0.35, tint: [217, 164, 106] },
  work: { focus: 6, glow: 0.25, tint: [163, 176, 204] },
  contact: { focus: 2, glow: 0.5, tint: [217, 164, 106] },
};

Keeping moods in one table makes them easy to tune. On this site I tweaked these numbers far more often than any of the code.

Step 2: style the backdrop with custom properties

CSS custom properties let JavaScript change the look without touching the CSS rules:

.backdrop {
  position: fixed;
  inset: 0;
  z-index: -1;
  --focus: 3;
  --glow: 0.35;
  --tint: 217 164 106;
  background:
    radial-gradient(
      circle at 50% calc(var(--focus) * 10%),
      rgb(var(--tint) / var(--glow)),
      transparent 60%
    ),
    #09090b;
}

--tint holds three space-separated numbers, so rgb(var(--tint) / var(--glow)) produces a colour with the glow as its alpha.

Step 3: find the active section

The obvious approach is a scroll listener that measures every section with getBoundingClientRect(). It works, but it runs on every scroll event and forces layout calculations while the user is scrolling.

IntersectionObserver does the measuring for us, off the main scroll path, and only tells us when something changes:

const target = { ...MOODS.default };

function setMood(id) {
  Object.assign(target, MOODS[id] || MOODS.default);
}

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) setMood(entry.target.dataset.section);
    }
  },
  { rootMargin: '-50% 0px -50% 0px' }
);

document.querySelectorAll('[data-section]').forEach((el) => observer.observe(el));

The trick: a line across the middle of the screen

rootMargin shrinks (or grows) the area the observer checks against. A margin of -50% at the top and bottom shrinks the viewport down to a single horizontal line across its centre.

A section is "intersecting" only while it crosses that line. Since sections stack one after another, exactly one section crosses the centre at any moment, so the observer fires once each time the active section changes. No thresholds to tune and no "which section is most visible?" maths.

Watch out for short final sections. If your last section is shorter than half the screen, its top may never reach the centre line, even when scrolled to the very bottom. Give it a min-height of at least 50vh, or add bottom padding to the page.

Step 4: ease towards the target, every frame

Now we have target values that jump instantly when the section changes. We want current values that glide towards them.

The classic way to write that is:

current += (target - current) * 0.1;

Each frame, move 10% of the remaining distance. It looks lovely, and it has a hidden bug: it's tied to the frame rate. On a 144Hz monitor that line runs more than twice as often as on a 60Hz one, so the animation is more than twice as fast. Your carefully tuned transition feels sluggish on one laptop and twitchy on another.

The fix is to base the easing on elapsed time:

const ease = (rate) => 1 - Math.exp(-dt * rate);
current += (target - current) * ease(2);

Here dt is the seconds since the last frame and rate controls the speed. Using an exponential means two short frames give exactly the same result as one long frame, so the motion is identical at any refresh rate.

Here's the full loop:

const backdrop = document.querySelector('.backdrop');
const current = { ...target, tint: [...target.tint] };
let last = performance.now();
let raf = 0;

function frame(now) {
  // Clamp so a long pause (like a background tab) doesn't cause a jump.
  const dt = Math.min((now - last) / 1000, 0.05);
  last = now;

  const ease = (rate) => 1 - Math.exp(-dt * rate);
  current.focus += (target.focus - current.focus) * ease(2);
  current.glow += (target.glow - current.glow) * ease(2);
  for (let i = 0; i < 3; i++) {
    current.tint[i] += (target.tint[i] - current.tint[i]) * ease(1.2);
  }

  backdrop.style.setProperty('--focus', current.focus.toFixed(3));
  backdrop.style.setProperty('--glow', current.glow.toFixed(3));
  backdrop.style.setProperty('--tint', current.tint.map(Math.round).join(' '));

  raf = requestAnimationFrame(frame);
}

raf = requestAnimationFrame(frame);

Notice that the colour eases more slowly (rate 1.2) than the focus and glow (rate 2). Slightly offset timings make a transition feel natural rather than mechanical. The real site uses the same split.

Step 5: be a good citizen

Two small additions make a big difference.

Stop when the tab is hidden

Browsers already slow requestAnimationFrame down in background tabs, but stopping it entirely saves battery, and resetting last avoids a jump when the user comes back:

document.addEventListener('visibilitychange', () => {
  cancelAnimationFrame(raf);
  if (!document.hidden) {
    last = performance.now();
    raf = requestAnimationFrame(frame);
  }
});

Respect reduced motion

Some people set their operating system to reduce motion because animation makes them feel unwell. For them, snap straight to each mood instead of animating:

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

When reduceMotion is true, skip the loop and apply the target values directly inside setMood. The page still changes mood; it just doesn't move to get there.

The React version on this site

On this site the backdrop is a WebGL scene, not a CSS gradient, but the structure is identical. The interesting part is how it fits into React without making React do the animation.

Don't put per-frame values in React state

The tempting approach is useState for the current mood. But the render loop changes values 60 to 144 times a second, and every state update re-renders a component. That's a lot of wasted work for values only the render loop reads.

Instead, the mood lives in a plain mutable object in its own module:

// sceneState.js
export const sceneState = { ...MOODS.default };

export function setActiveSection(section) {
  Object.assign(sceneState, MOODS[section?.id] || MOODS.default);
  listeners.forEach((fn) => fn(section));
}

The WebGL loop reads sceneState every frame, exactly like target in our demo. React never knows it changed.

Subscribe only to what the UI needs

Some UI does care about the active section. The header on the home page shows the current section's name. That component subscribes to section changes, which happen a handful of times per visit rather than 60 times a second:

const listeners = new Set();
let activeSection = null;

export function useActiveSection() {
  const [section, setSection] = useState(activeSection);
  useEffect(() => {
    listeners.add(setSection);
    return () => listeners.delete(setSection);
  }, []);
  return section;
}

So the split is:

  • Per-frame values: plain object, read by the render loop, no React
  • Discrete changes: a tiny subscription, so only the components that care re-render

The observer as a hook

The observer from step 3 becomes a hook that the home page calls once:

export function useSectionObserver() {
  useEffect(() => {
    const els = Array.from(document.querySelectorAll('[data-section]'));
    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (!entry.isIntersecting) return;
          const el = entry.target;
          setActiveSection({
            id: el.dataset.section,
            label: el.dataset.label,
            index: els.indexOf(el),
          });
        });
      },
      { rootMargin: '-50% 0px -50% 0px' }
    );
    els.forEach((el) => io.observe(el));
    return () => {
      io.disconnect();
      setActiveSection(null);
    };
  }, []);
}

The cleanup matters: when you navigate to another page, the observer disconnects and the scene drifts back to its default mood instead of staying stuck on the last section.

Where to take it next

  • Temporary overrides. This site also has a setFocusOverride() so hovering a project can pull focus without changing the section. The loop simply prefers the override when one is set.
  • Stop the loop when settled. If every value is within a tiny distance of its target, you can stop requesting frames until the next mood change.
  • Drive anything. Custom properties, canvas, WebGL uniforms, audio filter frequencies: anything with a number can have a mood.

If you build something with this, I'd love to see it. And if you want to go further, the other tutorials cover building and shipping real tools and games from scratch.

More tutorials