I built a decorative background layer for a long marketing page: a handful of large, blurred color blobs meant to drift behind the content as you scroll, giving frosted-glass cards something to actually blur into. I wrote it, checked it in the browser, saw color, called it done.
A day later someone told me the effect "looked the same as before." I was confused — I could see the blobs working. Except I'd only ever looked near the top of the page.
The setup
The background lived in its own component, absolutely positioned relative to a fixed viewport-height container, with a handful of blurred <span> elements inside it:
.ambient-background {
position: fixed;
inset: 0;
z-index: -10;
overflow: hidden;
pointer-events: none;
}
.ambient-background span {
position: absolute;
border-radius: 9999px;
filter: blur(70px);
}
And the blobs themselves were placed at increasing top offsets to spread them down the page:
<span style={{ top: '-8rem', left: '-6rem' }} />
<span style={{ top: '8rem', right: '-8rem' }} />
<span style={{ top: '48rem', left: '5%' }} />
<span style={{ top: '80rem', right: '0%' }} />
// ...and so on, down to top: 178rem
Looked reasonable. Rendered fine in the first screenshot. Shipped.
Why it was actually broken
position: fixed doesn't just make an element stay put while scrolling — it also makes its containing block the viewport, not the document. That means inset: 0 on a fixed element resolves to "cover the current viewport," full stop. It is not a tall box you can scroll through; it's permanently exactly one screen tall.
So every blob I'd placed at top: 48rem or beyond wasn't rendering somewhere further down a tall background — it was being laid out inside a box that only ever exists from 0 to ~100vh, and then clipped by overflow: hidden the instant its position fell outside that range. On a typical screen, only the first two blobs (roughly top: -8rem and top: 8rem) ever had a chance of being visible. Everything from 48rem down was being computed, laid out, and silently discarded on every single render.
The page in question was about 20,000px tall. My "sitewide background effect" was covering roughly the first 1,600px of it — 8% of the page — and I'd built it to assume otherwise.
How I actually found it
Eyeballing screenshots wasn't going to catch this, because the visible part looked correct. What caught it was walking the DOM directly and comparing element positions against the full document height:
const bg = document.querySelector('.ambient-background');
const spans = [...bg.querySelectorAll('span')].map(s => {
const r = s.getBoundingClientRect();
return { top: Math.round(r.top + window.scrollY), height: Math.round(r.height) };
});
console.log({ docHeight: document.body.scrollHeight, spans });
That printed exactly what you'd expect once you know the bug: every blob's computed top clustered in the first ~1,600px, regardless of what top value I'd set in the source. The moment I saw the numbers next to document.body.scrollHeight, the mismatch was obvious in a way no screenshot was going to show me.
The fix
Swap position: fixed for position: absolute, and make sure the containing block is actually the full document, not the viewport:
body {
position: relative; /* gives the absolute child a real containing block */
}
.ambient-background {
position: absolute; /* not fixed */
inset: 0;
z-index: -10;
overflow: hidden;
pointer-events: none;
}
With position: absolute, the element's containing block is the nearest positioned ancestor — here, <body> — and its inset: 0 now stretches to match <body>'s actual rendered height, which is determined by its normal-flow content (the real page). Because negative z-index descendants of a stacking-context-creating ancestor paint above that ancestor's own background and below its normal content, the blobs still show up behind everything else, but now they're mapped against the real document, not a 100vh window pretending to be one.
I also switched the blob positions from fixed rem offsets to percentages of the container (top: 12%, top: 44%, etc.), so the layout doesn't quietly break again the next time the page's content — and therefore its height — changes.
The takeaway
position: fixed and position: absolute look interchangeable in a lot of tutorials because for a short page, or an element near the top, they often render identically. The difference only bites when the element needs to represent something relative to the whole document, not the current screen — and by then, the bug is invisible unless you specifically go looking for it below the fold.
If you're building any kind of full-page decorative layer, background, or overlay: check its actual rendered position against document.body.scrollHeight, not just what's in the first viewport. A screenshot of the top of the page will lie to you.
Ran into this while working on DevFixel's marketing site. Curious if others have a cleaner pattern for full-document decorative layers — I'd take container queries over percentage math if there's a tidier way to do this.
Top comments (0)