After I Closed the Image Viewer with Esc, the Page Behind It Stopped Scrolling: What I Counted When Moving to the dialog Element
While fixing the full-screen image viewer on my own site, I worked through three problems in order: Esc did nothing, the page behind kept scrolling, and the close button sat on top of the artwork. Even after moving to dialog, one bug remained, with a check script and numbers.
As an indie developer who also builds sites for other artists, I keep one small image viewer on my own site, and tapping an image in the gallery opens it full screen. It is where people look at the artwork, so how it behaves is not something I can leave for later.
When I looked at it again, there was more than one thing to fix: the close button could sit on top of the artwork, the page behind it kept scrolling, and the keyboard did not work. This is a record of fixing the three in order, and of the moment my expectation that "moving to a dialog element would settle everything" turned out to be wrong.
Three symptoms with three different causes
I started by pairing each symptom with the cause I suspected and a way to check it.
Symptom
Suspected cause
How to check
Esc does not close it
No handler for the key
Press Esc and look at the open state
Tab moves to things behind the viewer
The page behind is still interactive
Press Tab a few times and record where focus goes
The page behind scrolls while it is open
Background scrolling is not stopped
Turn the wheel and watch scrollY
The close button sits on the image
The button is layered over the image
Measure the area where the two rectangles overlap
Looking at the page only reveals the last one. As long as you open and close it with a mouse, the keyboard problems never show up.
So I stopped looking for symptoms by eye and started counting them.
I counted the old version on a small test page
I built two test pages that contain only the parts that matter. One is the old structure, the other is the fixed one. The old structure is a full-screen div with the image and the close button inside it.
<!-- Old structure: just layer the image and close button in a full-screen div --><div id="ov"><button id="close" aria-label="close">×</button><img id="big" alt=""></div><style> #ov{position:fixed;inset:0;background:rgba(0,0,0,.9);display:none} #ov.open{display:block} #ov img{position:absolute;inset:0;margin:auto;width:auto;height:auto; max-width:100%;max-height:100%;object-fit:contain} #close{position:absolute;top:12px;right:12px;width:44px;height:44px;z-index:2}</style>
I prepared one tall image (800×2000) and one wide image (2400×900) on purpose. With an ordinary 2:3 image the picture does not reach the edge of the screen, so nothing overlaps. Overlap only happens with images that fill the screen. I had the wrong test image at first and got zero overlap on my first run.
The script below does the counting. It runs in Playwright's Chromium and prints numbers only.
// usage: node lightbox-check.js <file-or-url> <overlaySelector>const { chromium } = require('playwright');const path = require('path');const VIEWPORTS = [ { tag: 'phone-portrait', width: 390, height: 844 }, { tag: 'phone-landscape', width: 844, height: 390 }, { tag: 'desktop', width: 1280, height: 720 },];// Overlap area of two rectangles (px²). 0 means no overlapconst overlap = (a, b) => { const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x); const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y); return Math.round(Math.max(0, w) * Math.max(0, h));};(async () => { const [target, overlay] = process.argv.slice(2); const url = target.startsWith('http') ? target : 'file://' + path.resolve(target); const browser = await chromium.launch(); for (const vp of VIEWPORTS) { const page = await (await browser.newContext({ viewport: vp })).newPage(); await page.goto(url); const thumbs = await page.locator('[data-full]').count(); const row = { viewport: vp.tag, worstOverlapPx2: 0 }; for (let i = 0; i < thumbs; i++) { await page.evaluate((n) => document.querySelectorAll('[data-full]')[n].click(), i); await page.waitForTimeout(120); const img = await page.locator('#big').boundingBox(); const btn = await page.locator('#close').boundingBox(); row.worstOverlapPx2 = Math.max(row.worstOverlapPx2, overlap(img, btn)); if (i === thumbs - 2) { // While open: press Tab 4 times and see whether focus stays inside the overlay let inside = 0; for (let k = 0; k < 4; k++) { await page.keyboard.press('Tab'); inside += await page.evaluate((sel) => document.querySelector(sel).contains(document.activeElement) ? 1 : 0, overlay); } row.tabInsideOf4 = inside; // While open: does the page behind move with the wheel? await page.evaluate(() => scrollTo(0, 0)); await page.mouse.move(vp.width / 2, vp.height / 2); await page.mouse.wheel(0, 600); await page.waitForTimeout(150); row.scrollWhileOpen = await page.evaluate(() => scrollY); // After Esc: did it close, and does the page behind move? await page.evaluate(() => scrollTo(0, 0)); await page.keyboard.press('Escape'); await page.waitForTimeout(120); row.closedByEsc = await page.evaluate((sel) => { const el = document.querySelector(sel); return el.tagName === 'DIALOG' ? !el.open : !el.classList.contains('open'); }, overlay); await page.mouse.wheel(0, 600); await page.waitForTimeout(150); row.scrollAfterEsc = await page.evaluate(() => scrollY); } else { await page.evaluate((sel) => { const el = document.querySelector(sel); el.tagName === 'DIALOG' ? el.close() : el.classList.remove('open'); }, overlay); } } console.log(JSON.stringify(row)); } await browser.close();})();
Because what to count is decided up front, I can swap in a different test page and take the same measurements again. The i === thumbs - 2 branch avoids the last (wide) image and measures state on the tall one.
The old structure came out like this.
Viewport
Worst overlap
Tab presses (of 4) that stayed inside
scrollY while open
Closed by Esc
Phone portrait (390×844)
1311 px²
0 of 4
600
No
Phone landscape (844×390)
847 px²
0 of 4
600
No
Desktop (1280×720)
0 px²
0 of 4
600
No
The desktop overlap is zero because the image does not reach the edge of the screen. The numbers confirmed that the overlap only appears with a full-bleed, elongated image on a phone.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can count four things in a few minutes with a small check script, before you polish an image viewer: Esc, Tab, background scrolling, and whether the close button overlaps the image
✦You can prevent the bug where scrolling stays locked after closing with Esc, by deciding where cleanup lives in a component that can be closed in several ways
✦You can copy a layout where the close button never overlaps tall or wide images into your own page, and write it into the instructions you give an agent
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Moving to the dialog element removed the keyboard problems
For the keyboard and the background, relying on the browser was more dependable than writing it myself. A dialog opened with showModal() gives you these by default.
Esc closes it
The page behind becomes non-interactive, so Tab does not leave the dialog
On close, focus returns to the element that had it before opening
This is the structure after the move. The close button goes into its own bar instead of sitting on the image.
<dialog class="lb" id="lb" aria-label="Enlarged image"> <div class="lb-bar"><button id="close" type="button" aria-label="Close">×</button></div> <div class="lb-stage"><img id="big" alt=""></div></dialog><style> html{scrollbar-gutter:stable} body:has(dialog[open]){overflow:hidden} dialog.lb{border:0;padding:0;margin:0;width:100%;height:100%; max-width:none;max-height:none;background:rgba(0,0,0,.92); color:#fff;overscroll-behavior:contain} dialog.lb[open]{display:grid;grid-template-rows:auto minmax(0,1fr)} dialog.lb::backdrop{background:rgba(0,0,0,.92)} /* The close button goes outside the image area (in the top bar), not on the image */ .lb-bar{display:flex;justify-content:flex-end; padding:max(8px,env(safe-area-inset-top)) max(8px,env(safe-area-inset-right)) 8px 8px} .lb-bar button{width:44px;height:44px;font-size:24px} /* The image fits the remaining area. Without minmax(0,1fr) a tall image pushes the row open */ .lb-stage{min-height:0;display:grid;place-items:center; padding:0 max(8px,env(safe-area-inset-right)) max(8px,env(safe-area-inset-bottom)) max(8px,env(safe-area-inset-left))} .lb-stage img{max-width:100%;max-height:100%;width:auto;height:auto; object-fit:contain;display:block}</style><script> const lb = document.getElementById('lb'); const big = document.getElementById('big'); document.querySelectorAll('[data-full]').forEach((b) => b.addEventListener('click', () => { big.src = b.dataset.full; big.alt = b.querySelector('img').alt; lb.showModal(); })); document.getElementById('close').addEventListener('click', () => lb.close()); // Also close when the margin (the dialog itself) is clicked, which is neither the image nor the bar lb.addEventListener('click', (e) => { if (e.target === lb) lb.close(); });</script>
Each thumbnail is a button whose data-full holds the location of the large image. As a button it can be reached with Tab and opened with Enter.
The same script gave these numbers.
Viewport
Worst overlap
Tab presses (of 4) that stayed inside
scrollY while open
Closed by Esc
Phone portrait
0 px²
2 of 4
0
Yes
Phone landscape
0 px²
2 of 4
0
Yes
Desktop
0 px²
2 of 4
0
Yes
"2 of 4" is not a miss. Inside a dialog, Tab alternates between the close button and the browser's own interface, such as the address bar. While focus is outside the page, document.activeElement is body, so the count halves. What matters is that focus never reaches a thumbnail or link behind the dialog. In the old version, none of the four presses landed inside the viewer; focus stayed on the page behind it.
What I did not expect: scrolling after closing with Esc
I thought it was fixed. Then I wrote the background lock the "usual" way and a different bug appeared.
// The usual approach (Before): add a marker on open, remove it inside the close buttonlb.showModal();document.body.classList.add('lock'); // body.lock { overflow: hidden }closeBtn.addEventListener('click', () => { lb.close(); document.body.classList.remove('lock'); // <- Esc never goes through here});
In the script's scrollAfterEsc, scrollY stayed at 0 after closing with Esc.
Approach
scrollY while open
scrollY after closing with Esc (wheel 600)
Class toggled by hand (Before)
0
0 (the page is stuck)
:has(dialog[open]) (After)
0
600 (it scrolls)
Esc closes a dialog through its cancel path, which never goes through the close button's handler. Only the lock class stays behind, and the page looks frozen. You will not hit that path if you only test with touch, so it is a bug that is easy to miss when you do not use the keyboard.
The fix was to stop toggling a marker by hand and let CSS read the state itself: is the dialog open right now.
/* After: lock the page only while a dialog is open, regardless of how it closes */body:has(dialog[open]) { overflow: hidden; }
Whether it closes by the button, a click on the margin, Esc, or a call to lb.close(), the lock goes away the moment the open attribute does.
For a component that can be closed in several ways, attach cleanup to the closed state, not to the closing action. That was what I took away this time.
I stopped putting the close button in the same area as the image
There were other ways to reduce overlap: a smaller button, a shrunken image, a translucent background. But all of them assume that overlap will happen.
I reserved one row for a bar and fit the image into the remaining area, so the structure itself cannot overlap. The minmax(0, 1fr) in grid-template-rows: auto minmax(0, 1fr) is the key. Without it, a tall image pushes the row open and the bar can slide off screen.
The safe areas are added with env(safe-area-inset-*) and max() so the bar is not hidden on devices with a notch, but I could not verify that part in Chromium. The next section lists what these numbers cannot tell you.
What these numbers do not tell you
Every number here comes from Playwright's Chromium. These are outside what I checked.
The behavior of WebKit (Safari on iPhone). It would not launch in my environment, so I did not run it
The real safe-area margins. On a screen without a notch they are always 0
The effect of scrollbar-gutter: stable. Headless scrollbars take no width, so I could not measure any layout shift
For what it is worth, I finish this kind of fix on a real device too. On my own site, opening one tall and one wide artwork in both phone orientations and checking the button position and scrolling after closing, by hand, was the deciding step. Counting with a script that nothing got worse, and then confirming on a device that it is pleasant to use, works well as two stages.
An instruction to give your agent
When I have an Antigravity agent build or fix an image viewer, I hand over the things to verify first rather than only the finished look.
Implement the image viewer with a dialog element (showModal) and meet these conditions.1. Put the close button outside the image area (in a top bar) so it never overlaps the image2. Lock background scrolling with body:has(dialog[open]) and do not toggle a class from JS (so the lock is released even when the dialog is closed with Esc)3. Make thumbnails button elements that open with Tab and Enter4. After implementing, report the results of lightbox-check.js (overlap, Tab, scrollY, Esc) using a tall image (800x2000) and a wide image (2400x900). If you could not check an item, say that you could not.
The last line reduces the cases where something that was not checked gets summarized as "no problem".
Pitfalls
When you set display:grid on a dialog, limit it to dialog[open]. If you write it for plain dialog, closed dialogs show up too
Set the default max-width and max-height of dialog to none, otherwise it will not be full screen
When the image loads slowly, only the bar appears first. Adding width and height attributes to the img keeps the row height stable
Adding z-index to the old structure will not fix the keyboard or the background scrolling. Those are separate problems from overlap
What to try next
Run your own viewer through lightbox-check.js and take the four numbers. Overlap shows up when you open tall and wide images in both phone orientations, and the Esc result is easiest to catch with a keyboard attached.
If you try the script on your own viewer, I would be glad to hear which of the four numbers surprised you.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.