I scrolled through five full screens and never saw a single photo. The four photos that were supposed to cross fade as I scrolled never appeared, and all I scrolled past was that much empty space.
Console clean. Network tab showing every photo downloaded with a 200. The files arrived, nothing put them on screen.
The symptom: the height was there, the content was not
This was part of a smart home feature page on a client site. The concept was simple enough on paper: one section at 500vh, sticky contents, and four photos of a day to night cycle cross fading as you scroll through it. The client loved the idea, so it had to work.
What actually rendered: a long scroll, a sticky wrapper that stuck correctly, and a stage with absolutely nothing in it. Not a broken image icon, not a partially loaded frame. Nothing.
When I opened DevTools and selected the first photo element, the numbers explained everything at once. It had a width. Its height was zero. The element existed in the DOM, it just occupied no space whatsoever.
Root cause one: object-fit applied to <source>
The photo markup at that point was a classic <picture>, with a few <source> entries split by media and an <img> at the bottom as the fallback:
<div class="day-cycle__stage">
<picture class="day-cycle__photo">
<source media="(min-width: 1200px)" srcset="morning_w1920.webp">
<source media="(min-width: 720px)" srcset="morning_w1280.webp">
<img src="morning_w720.webp" alt="">
</picture>
</div>And the CSS, this is the part where I got to blame myself, targeted <source>:
/* Does absolutely nothing */
.day-cycle__photo source {
width: 100%;
height: 100%;
object-fit: cover;
}That rule was written, parsed by the browser, listed neatly in the Styles panel, and had zero effect. The reason: <source> is not a rendered element. It is metadata. Its only job is to tell the browser which candidate file to fetch, and after that it drops out of layout entirely. It never has a box, so width, height, and object-fit on it are notes written on paper that gets thrown away immediately.
The nasty part is that DevTools does not complain. No strikethrough like an overridden property, no warning. The selector is valid, the properties are valid, only the element type is wrong.
Root cause two: <picture> is display:inline
Even if I had fixed the selector to point at img, the photos still would not have shown up correctly, because a second problem was stacked on top.
<picture> is not a magic wrapper. It is an ordinary element with display: inline by default. So when I put .day-cycle__photo on the <picture> and expected it to become an absolutely positioned layer filling the stage, I was really asking an inline box to behave like a block. Meanwhile the <img> inside it, the only element in that whole structure that actually renders, was given no height at all.
The two together produce exactly what I was looking at: the sizing rules landed on a non rendered element, the rendered element got no sizing, and the photo layer collapsed to zero.
The fix: drop <picture>, use srcset on <img>
Once the cause was clear, the fix simplified things rather than complicating them. <picture> with media earns its keep when you genuinely need art direction, like a different crop on mobile than on desktop. That was not the case here. All I needed was the same photo in three sizes with the browser picking the best one.
For that, srcset with width descriptors is enough, and it leaves exactly one rendered element behind:
<img
class="day-cycle__photo"
src="morning_w1280.webp"
srcset="morning_w720.webp 720w,
morning_w1280.webp 1280w,
morning_w1920.webp 1920w"
sizes="100vw"
alt=""
>sizes="100vw" is honest here, because the photo really does span the viewport. From that the browser works out which candidate makes sense for the screen width and device pixel ratio in front of it.
And the styling now attaches directly to the element that owns a real box:
.day-cycle__photo {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}The display: block in there is not decoration. <img> is inline by default too, and I would rather state it than rely on position: absolute happening to blockify the element as a side effect. Written once, there is nothing left to remember when the class gets reused somewhere else.
Three other things I closed while I was in there
Once the photos appeared, I patched a few holes that were already queuing up to become the next bug.
Section height needs a fallback before svh. The svh unit is lovely on mobile because it does not jump when the browser address bar collapses, but older browsers do not know it. If the only value is unrecognised, the declaration gets dropped and the section loses its height entirely. So write both:
.day-cycle {
height: 500vh;
height: 500svh;
}Old browsers stop at the first line, new ones override it with the second. No @supports needed.
Sticky and stage wrappers need explicit sizing. height: 100% only works if the parent has a height to resolve against. That chain breaks silently in the middle very easily, and the symptom is identical to the bug above: an absolutely positioned child collapsing to zero. So every level down to the photo stage states its size:
.day-cycle__sticky,
.day-cycle__stage {
width: 100%;
height: 100%;
}Layer order stated explicitly. I had been leaning on DOM order alone for the stacking. The moment every photo became absolutely positioned, that got fragile. Now the dark veil and the text content each carry a number:
.day-cycle__veil { z-index: 1; } /* above the photos */
.day-cycle__content { z-index: 2; } /* above the veil */Photos stay on the base layer with no z-index, the veil sits over them to keep text contrast, and the content sits on top.
What I took away
<source>does not render. Anything you write for it,width,height,object-fit, will never have a visual effect. Responsive image styling always belongs on the<img>.<picture>is not a layout wrapper. It defaults todisplay: inline, so handing it a positioning and sizing class without touchingdisplayis asking for trouble.- If all you need is one image at several resolutions,
srcsetplussizeson a plain<img>covers it. Save<picture>for when you actually need art direction. - A zero height element in DevTools is the fastest clue available. When the height reads zero while the CSS rule looks perfectly active, the question is not "why isn't this rule working" but "is this rule landing on the right element".
- With modern viewport units, always write
vhfirst andsvhsecond. An unrecognised declaration is dropped silently, and you do not want your section height to disappear over one unit.