· Learnings · 6 min read

Building a Layered Scroll Background with CSS View Timelines

A layered landscape can make a long page feel alive, but tying several large images to scroll introduces its own design and debugging constraints. I built one with transparent image layers, CSS view timelines, and a few safeguards that keep the content in charge.

Close-up of overlapping vertical panels in blue and pink light beneath a pale sky.

I wanted to give a longer page a sense of movement without turning every section into a separate animation. A single landscape seemed like a gentler option: it could remain behind the content while parts of the scene moved away as the reader progressed.

The finished layered background example uses five images from one photograph. The nearby balloons rise first, followed by the distant balloons. The sky and mountains fade, then the foreground hills fall below the viewport. The sequence makes the page feel as though it is being peeled apart, but the text remains the reason to scroll.

Separating the Photograph into Useful Layers

I started with a landscape that already had clear depth: balloons, sky, mountains, and foreground hills. I used Canva Magic Layers to separate those parts, with a prompt similar to:

mountains, then separating the foreground hills from the background
mountains.

The automatic separation was only the beginning. Each exported image needed transparent space around the subject so that all five files could occupy the same viewport. I ended up with separate layers for the foreground balloons, background balloons, sky, background mountains, and foreground hills.

The edges of the source image also affected the layout. Some balloons were cut off in the original photograph, so centering those layers exposed the crop. Anchoring the foreground balloons to the left and the background balloons to the right made the crop look intentional and preserved the original composition.

Keeping One Scene Behind the Story

The scene is a fixed container that fills the viewport. Every image is positioned absolutely inside it, while the story sits above it:

position: fixed;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}

.layered-story {
  position: relative;
  z-index: 10;
}

Each story section has a minimum height of 100vh. That gives every layer enough scroll distance to complete its movement before the next one begins. A translucent card and backdrop-filter keep the copy readable over both the bright sky and the darker terrain.

Layer order needed more care than the array of image paths. The sky sits at the back, the landscape layers occupy the middle, and both balloon layers sit in front. Explicit z-index values made that relationship visible in the CSS instead of relying on document order. This became especially useful when adjusting the mountains and hills, since two images can appear correctly placed while one still obscures the useful part of another.

Connecting Motion to Each Section

My first version attached every animation to scroll(root block) and used viewport units for the ranges. It worked, but it made each range depend on the total document scroll position. Adding or resizing a section could shift the relationship between the story and the layer it controlled.

I replaced that setup with one named view timeline per story section. The shared page exposes those timelines to the fixed scene:

timeline-scope: --layered-near, --layered-far, --layered-sky, --layered-mountains, --layered-hills;
}

.beat-near {
  view-timeline: --layered-near block;
}

The corresponding image can then follow the section rather than the entire document:

animation: layered-lift linear both;
  animation-timeline: --layered-near;
  animation-range: exit 10% exit 90%;
}

This was a better model for the page. The animation starts after the section begins to leave and finishes before it has fully exited. The same pattern controls the fades and the downward movement of the hills. It also means that changing one section does not require recalculating every range that follows it.

The “Scroll to continue” prompt uses the first section's timeline as well. Its opacity falls during the opening part of that section's exit, and the same timeline naturally restores it when the reader returns to the top. No scroll listener or permanent hidden state is necessary.

Treating Image Weight as Part of the Animation

Transparent PNG layers can become expensive delivery assets even when their visible content looks simple. The original sky was about 2.1 MB, while the foreground hills were about 4.4 MB. I losslessly recompressed the sky to about 1.5 MB and resized the hills from 2400×831 to 1600×554, bringing it to about 2 MB.

That optimization does not change how the CSS animation runs after an image is decoded, but it reduces the initial transfer and the amount of image data the browser must decode. It also reinforced a practical rule for this kind of effect: prepare each layer for the largest size it will actually render, not the dimensions of the source photograph.

Separating Page Problems from Browser Problems

The most misleading part of the work was a pause at the top and bottom of the page after flicking an infinite scroll wheel. CPU usage and JavaScript heap size stayed flat, so I initially suspected compositor work from the timelines and backdrop blur. Moving from root scroll timelines to view timelines improved the structure, but it did not eliminate the pause.

The behavior disappeared in Chrome Incognito, where my extensions were disabled. That changed the diagnosis: the queued input was related to an extension, not evidence that the page itself was holding wheel events. Testing in a clean browser profile should have happened before changing the implementation.

Chrome's style recalculation counter was useful here because it stayed quiet while the page appeared stuck. That helped rule out repeated style invalidation and, together with the pause occurring only after a long wheel flick, pointed toward a backlog of scroll input instead. The counter does not measure paint work directly, so it could not rule out repainting by itself. Confirming that the behavior disappeared in a clean browser profile was still the important final check.

I also chose not to add contain: paint to the document scroller. Paint containment clips descendants and can change how fixed or sticky elements are contained, which works against a scene designed to remain fixed behind the page. It would not address extension-generated input in any case.

Fallbacks Still Matter

Scroll-driven motion should not be the only way to understand the page. The text and scene remain usable without animation, and the CSS disables the effects when the reader requests reduced motion. A support query also removes the animations in browsers without scroll timeline support.

These fallbacks are intentionally simple. The static composition already communicates the setting, so unsupported motion does not need a JavaScript replacement.

Final Thoughts

I will keep the view-timeline version because it connects each visual change to the section that explains it. That relationship is easier to adjust than a list of document-wide offsets, and it lets the animation reverse naturally when someone scrolls upward.

The more important lesson was diagnostic. A visual pause near an animation is not automatically caused by the animation, and a quiet CPU graph does not identify where the delay originates. Comparing the same page in a clean browser separated the extension behavior from the page behavior and kept me from optimizing the wrong problem. The result is still a small decorative effect, but its structure is one I can reuse without asking the reader to pay for unnecessary complexity.

Back to Blog

Related Posts

View All Posts »
Implementing an AI RAG for Bylaw Retrieval

Implementing an AI RAG for Bylaw Retrieval

A bylaw search assistant should find the right clauses without making the rules up as it goes. Building one taught me that retrieval quality, citation quality, and consistency all need their own tests.

© 2026 Sea Otter Studios All rights reserved.