◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-10-30Intermediate

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.

dialog elementlightboxaccessibility4Playwright5indie development28Antigravity409

✦ Premium Article

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.

SymptomSuspected causeHow to check
Esc does not close itNo handler for the keyPress Esc and look at the open state
Tab moves to things behind the viewerThe page behind is still interactivePress Tab a few times and record where focus goes
The page behind scrolls while it is openBackground scrolling is not stoppedTurn the wheel and watch scrollY
The close button sits on the imageThe button is layered over the imageMeasure 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 overlap
const 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.

ViewportWorst overlapTab presses (of 4) that stayed insidescrollY while openClosed by Esc
Phone portrait (390×844)1311 px²0 of 4600No
Phone landscape (844×390)847 px²0 of 4600No
Desktop (1280×720)0 px²0 of 4600No

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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

▣ App Dev2026-10-20
I Edited a Sentence and Only the New Characters Turned into Tofu: Adding a Missing-Glyph Check to a Subsetted Japanese Web Font
Change one line of Japanese copy and the newly added characters can render as tofu boxes. Here is a small script that lists every character missing from a subsetted font, with file and line number, and fails the build if even one is absent, with real output from running it.
▣ App Dev2026-09-19
I Asked for the Whole Quote PDF Tool at Once, Then Split It Into Four Requests
How I moved my client quote paperwork from copy-and-edit to a small PDF tool built with Antigravity, why the first single-shot request had to be thrown away, and the four smaller requests that replaced it, with working code.
✦ Tips2026-10-19
After I Rewrote the URL, Only the Not-Yet-Loaded Images Returned 404: Checking replaceState and Relative Paths
After rewriting the URL with history.replaceState when moving from a list to a detail view, only the images that had not loaded yet returned 404. Native loading=lazy broke too. Here are a minimal test page, a check script, and three fixes compared.