·4 min read

opacity 0 does not hide anything

My lightbox was invisible and still in the tab order. Fixing that broke focus handling silently, because focus() on a hidden element reports nothing.

accessibilitycssjavascriptweb
The lightbox, dependency-freeGist · IndieCoreDev/94c7819fac39ad0f3b181aa84013a414

Lighthouse has a list of accessibility items it will not judge for you. Interactive controls are keyboard focusable. The page has a logical tab order. Offscreen content is hidden from assistive technology. No score, no pass, no fail — a list of things a machine cannot decide.

I had been treating that list as decoration. Then I actually worked it, one item at a time, by driving the running page rather than reading my own markup, and three of the eleven turned out to be real.

All three were the same component.

Invisible is not gone

My screenshot lightbox is built by JavaScript on page load and hidden until someone clicks a thumbnail. Hidden like this:

.lb { position: fixed; inset: 0; display: flex;
      opacity: 0; pointer-events: none; transition: opacity .3s }
.lb.open { opacity: 1; pointer-events: auto }

That is a normal way to write a fade. opacity: 0 makes it invisible. pointer-events: none makes it unclickable. Between them they cover mouse users completely.

They do nothing at all for the keyboard. The element still has layout, still sits in the accessibility tree, and its three buttons are still tabbable. I counted them in the console:

lbButtonsTabbable: [ 'Previous screenshot', 'Next screenshot', 'Close' ]
totalTabbable: 52

On every game page, a keyboard user tabbing through the footer would land on three controls that were not on the screen. Press Enter on one and a dialog they cannot see starts responding.

visibility: hidden fixes it, because visibility is inherited by hit-testing, focus and assistive technology in a way opacity is not. Tab stops went from 52 to 49.

Then I broke the thing that was working

Here is the part I did not see coming. Opening the dialog does this:

lb.classList.add('open');
document.body.style.overflow = 'hidden';
lb.querySelector('.lb-close').focus();

That worked before my change. After it, focus stayed on the thumbnail and the dialog opened behind the user — the exact failure the managed focus item on that list describes, newly introduced by fixing a different item on the same list.

focus() on an element that is visibility: hidden does nothing. It does not throw. It does not warn. It returns, and document.activeElement is whatever it was before.

And at the moment .focus() runs, the element still is hidden, because I had put visibility in the transition:

transition: opacity .3s, visibility .3s;

With a duration on it, the computed value does not flip until the transition starts on the next frame. The class is applied, the style says .open, and getComputedStyle still reports hidden for one more tick. I confirmed that by reading it twice — immediately after the click, and 600 ms later:

same tick:   { hasOpenClass: true, visibility: 'hidden',  opacity: '0' }
after 600ms: { hasOpenClass: true, visibility: 'visible', opacity: '1' }

I tried forcing a reflow with void lb.offsetWidth first. That did not help, which is the clue that it was never a style-flush problem. The fix is to take visibility out of the timed part and delay it only on the way out:

.lb      { visibility: hidden;  transition: opacity .3s, visibility 0s .3s }
.lb.open { visibility: visible; transition: opacity .3s, visibility 0s 0s }

Zero duration, no delay when opening, so it flips in the same tick and focus() lands. Delayed by the length of the fade when closing, so the dialog does not vanish mid-animation.

Testing it by pressing the keys

I could not have found either bug by reading the code, because both times the code says the right thing. The class gets added. .focus() gets called.

So I drove it over the DevTools protocol instead — real Tab and Escape key events dispatched at the page, printing document.activeElement after each one:

before open, focus: BUTTON[Enlarge screenshot 1 of 5].shot
after open,  focus: BUTTON[Close].lb-close
tabbing forward:    lb-prev → lb-next → lb-close → lb-prev → lb-next → lb-close
shift+tab:          lb-next
after Escape:       BUTTON[Enlarge screenshot 1 of 5].shot

That is the whole contract for a modal, visible as output. Focus enters, cycles without escaping, and returns to whatever opened it. The first run of that same script printed BUTTON[Enlarge screenshot 1 of 5] on the second line, which is how I knew.

The dialog also needed to say what it was. It had no role, so a screen reader announced three buttons with no indication that anything had opened:

lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-modal', 'true');
lb.setAttribute('aria-label', 'Screenshot viewer');

aria-modal tells assistive technology to ignore the rest of the page. It does not stop Tab from walking out into it, so the trap is still yours to write.

The one I would look at first on your site

Any element you hide with opacity and reveal with a class. Carousels, drawers, tooltips, cookie banners, off-canvas menus. If it contains a link or a button, tab through your page with your eyes on the screen and see where focus goes when it disappears.

Mine had been shipping like that for months, on six pages, and every automated audit I ran gave the site 100 for accessibility the entire time.

More posts

New writing when there is something worth saying. By email or by RSS, whichever you prefer.