DEV Community

Artur Smirnov
Artur Smirnov

Posted on

The image loads and stays invisible: the opacity: 0 is on the wrapper, not the image

Someone posted this on a support forum and got no answers for a week:

The featured image is displayed correctly on desktop browsers and iPad (Chrome and Safari), but
the featured image is missing on iPhone. […] HTML output contains the <figure> and <img>
element […] No JavaScript errors in the browser console. CSS shows normal values (display: block,
correct height, opacity 1).

Every sentence of that is true, carefully checked, and points away from the cause.

The image is in the markup. The image is downloaded. getComputedStyle on the <img> returns
display: block, a correct height, opacity: 1. Nothing is wrong with the image, and he had spent
a week establishing that with increasing precision.

It took me one look at the page source to find it, and only because I was looking one level up.

What was actually there

The theme wraps the featured image in a <figure> and puts an animation class on it:

<figure class="ext-animate--on wp-block-post-featured-image">
  <img src="…">
</figure>
Enter fullscreen mode Exit fullscreen mode

And in the stylesheet, exactly one rule:

.ext-animate--on:not(.ext-animate--off) { opacity: 0; transform: scale(0.8); }
Enter fullscreen mode Exit fullscreen mode

The class is removed by the theme's own JavaScript once the element scrolls into view. That is the
entire fade-in mechanism: start at zero, let a script take the class off, the CSS transition does the
rest.

If that script does not run, or does not reach this element, the wrapper stays at opacity: 0
forever. The image inside it is loaded, laid out, correct in every respect, and invisible — because
you cannot see through a transparent parent no matter how opaque the child is.

His own investigation could not find this, and it is worth being precise about why: he inspected
the image, and the zero was on the figure.
Every value he read was genuinely normal. The tooling
told him the truth about the wrong element.

(The device-specific part has a likely explanation in the same file: the theme respects
prefers-reduced-motion. If "Reduce Motion" is on in iOS accessibility settings, the reveal can be
suppressed while the starting state stays put — visible everywhere, invisible on exactly one phone.
That one I flagged as a suspicion rather than a measurement, because I could not reproduce it on his
device.)

The general shape

Anything that hides an element hides its subtree, and none of it shows up on the child:

on an ancestor the child's own computed style
opacity: 0 opacity: 1 — untouched
visibility: hidden inherited, so this one does show
height: 0 + overflow: hidden correct height, correct everything
transform: scale(0) no trace
clip-path: inset(100%) no trace
content-visibility: hidden no trace
a ::before overlay painted on top nothing at all, in any property

So when an element is loaded, laid out, and not on screen, stop reading its style and walk up:

function whyInvisible(el) {
  const out = [];
  for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
    const s = getComputedStyle(n);
    const r = n.getBoundingClientRect();
    const bad = [];
    if (+s.opacity === 0)            bad.push('opacity:0');
    if (s.visibility === 'hidden')   bad.push('visibility:hidden');
    if (s.display === 'none')        bad.push('display:none');
    if (s.transform.includes('matrix(0') ) bad.push('transform scaled to 0');
    if (s.clipPath !== 'none')       bad.push('clip-path:' + s.clipPath);
    if (r.width === 0 || r.height === 0) bad.push(`box ${r.width}×${r.height}`);
    if (bad.length) out.push([n.tagName.toLowerCase() + '.' + n.className, bad.join(', ')]);
  }
  console.table(out);
}

whyInvisible(document.querySelector('img.your-image'));
Enter fullscreen mode Exit fullscreen mode

Run that, and the answer is a row in a table instead of a week of forum silence.

One more habit worth building, because it costs nothing: when a fade-in is involved, check the
element with JavaScript disabled. Reveal-on-scroll animations written as "start hidden, let a script
un-hide" have a failure mode with no error message — the script that was supposed to fix it simply
never ran, and the page looks like the CSS is broken. If your site does this, the starting state
should be visible and the animation should be an enhancement, not the other way round.

The other half: when the image is fine and your measurement is broken

I audit sites from the outside and write to the owners, which means my detector has to be right
before I put a claim in an email. Three of the ways it lied to me, all on live sites, all inside one
week:

"Eleven broken images." The detector flagged every image where naturalWidth === 0 after
scrolling to the bottom. All eleven were carousel slides that vertical scrolling never wakes — and
their URLs still carried &width=30, which is the theme's placeholder. Nothing was broken. Fix:
never claim a broken image from the DOM; make an actual request for each candidate URL and only
report 4xx/5xx from the server. On another store that same check confirmed eight genuine 404s, so
it removes fiction without removing findings.

"Two hundred and forty-eight broken images, including the logo." Every image on the site lived on
a separate image host, and every single one came back 403 with Cf-Mitigated: challenge and a
three-kilobyte body, while the main domain answered 200. Cloudflare was challenging my address,
and an <img> request cannot solve a challenge, so all of them failed at once.

That one has a heuristic worth memorising, because it identifies the class instantly: if the
company logo is also "broken", it is almost certainly you.
A live business notices a missing logo
the same day. A hundred per cent failure rate is a property of your connection, not of their site.
The same goes for 406 — some servers return it for short User-Agent strings and 200 for a full
Chrome one.

"A whole row of products faded to nothing." The screenshot showed it twice, and the computed
style backed it up: opacity: 0.01 on the slider wrapper. Which is the exact bug from the first half
of this article, so I nearly sent it. It was false. The theme reveals blocks with
IntersectionObserver, my script was paging through the document at 320 ms per screen and then
calling scrollIntoView, which restarted the reveal. At 450 ms per screen with a three-second hold,
every block read opacity: 1, on three separate pages.

Note the sting in that: a computed style is usually the thing that settles an argument, and here it
lied, because I photographed an animation mid-flight. The distinguishing test is the distribution.
Some blocks at 1 and some at 0.01 is their code. All of them faded, or none, is your own scrolling.

The rule underneath all four

Every one of these is the same mistake wearing a different costume: I measured the environment and
attributed it to the object.

The check is cheap. Take three of your results at random and look at them by eye. If they all point
at the same thing — the same host, the same wrapper, the same status code — you have found a property
of the page's furniture or of your own connection, not a property of the objects you thought you were
measuring. Real defects do not distribute that evenly. A finding that comes back at a hundred per cent
or at zero per cent is a finding about your instrument.

And before anything goes in front of the person who owns the site: confirm it a second way, and let
the second way be different in kind. Not the same script run twice — a different tool, or a different
angle. Over one week that rule killed roughly two thirds of what my detector produced, and every
single thing that survived it held up.


If you have an element that is loaded and invisible, paste the whyInvisible function above into the
console and point it at the element. It is usually the second or third row.

I do audits like this on real sites, and the repairs that follow.
Portfolio: smirnov-artur.github.io/webgl ·
Telegram @smirnovarturr · paladei702@gmail.com.

Top comments (0)