◉ANTIGRAVITY LABEN
記事一覧/アプリ開発
▣ アプリ開発/2026-10-30中級

画像の拡大表示を Esc で閉じたら、背面のスクロールが戻らなくなりました。dialog 要素に載せ替えて数えたこと

作品の拡大表示を直したとき、Esc で閉じない・背面が動く・閉じるボタンが画像に重なる、の三つを順に潰しました。dialog 要素へ載せ替えても残った「Esc のあとスクロールが戻らない」不具合まで、確認スクリプトと数字で書き残します。

dialog要素ライトボックスアクセシビリティ3Playwright5個人開発123Antigravity409

✦ プレミアム記事

個人開発の小さなサイトですが、作品の一覧から画像をタップすると、全画面で拡大して見られる表示を付けておりました。作品を見ていただく場所なので、拡大表示の操作感は後回しにできません。

見直してみると、直すべきところは一つではありませんでした。閉じるボタンが作品に重なること、背面がスクロールしてしまうこと、キーボードで操作できないことです。三つを順に直した記録と、その途中で「dialog 要素に載せ替えれば全部片づく」という予想が外れた箇所を、お伝えします。

三つの症状は、別々の原因でした

最初に整理したのは、症状と、疑っていた原因の組み合わせです。

症状疑った原因確認の方法
Esc を押しても閉じないキー入力を受ける処理がないEsc を押して開閉の状態を見る
開いたまま Tab を押すと、背面の部品に移る背面を操作対象から外していないTab を数回押して、フォーカスの行き先を記録する
開いたまま画面をなぞると、背面が動く背面のスクロールを止めていないホイールを回して scrollY の変化を見る
閉じるボタンが画像に乗るボタンを画像の上に重ねている画像とボタンの矩形が重なる面積を測る

見た目の確認だけだと、四つ目以外は気づきにくいのが厄介でした。マウスで開閉している限り、キーボードの問題は表に出ません。

そこで、症状を目で探すのをやめて、数えることにしました。

古い作りを、手元の確認ページで数えました

実サイトの条件は持ち込まず、必要な部品だけに削った確認ページを二つ作りました。一つは古い作り、もう一つは直したあとの作りです。古い作りは、全画面の div を重ねて、その中に画像と閉じるボタンを置いただけです。

<!-- 古い作り: 全画面の 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>

縦長(800×2000)と横長(2400×900)の画像を、あえて一枚ずつ用意しています。普通の縦横比(2対3)の画像だと、画面の端まで届かないので、ボタンが重なりません。重なりは「画面いっぱいに広がる画像」でだけ起きます。ここで一度、確認用の画像を間違えていて、最初は重なりがゼロと出ました。

数えるのは、次のスクリプトです。Playwright の Chromium で動かし、見た目ではなく数字だけを出します。

// 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 },
];
 
// 二つの矩形の重なり面積(px²)。0 なら重なっていません
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) {
        // 開いている間: Tab を 4 回押して、フォーカスが重ね表示の内側にとどまるか
        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;
        // 開いている間: ホイールで背面が動くか
        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);
        // Esc のあと: 閉じたか、背面は動くか
        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();
})();

何を数えるかを先に決めてあるので、確認用のページを差し替えるだけで、同じ指標を繰り返し取れます。i === thumbs - 2 の分岐は、最後の一枚(横長)を避けて、縦長の画像で状態を測るためのものです。

古い作りの結果は、次のとおりでした。

表示環境重なり(最大)Tab で内側にとどまった回数開いている間の scrollYEsc で閉じたか
スマホ縦(390×844)1311 px²4回中0回600閉じない
スマホ横(844×390)847 px²4回中0回600閉じない
デスクトップ(1280×720)0 px²4回中0回600閉じない

デスクトップで重なりがゼロなのは、画像が画面の端まで届かないからです。重なりは、画面いっぱいに広がる細長い画像をスマホで開いたときにだけ出る、ということが数字で確かめられました。

✦

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
✦拡大表示を作り込む前に、Esc・Tab・背面スクロール・ボタンの重なりの四点を、数分で動く確認スクリプトで数えられるようになります
✦Esc で閉じたあとスクロールが戻らない不具合を、閉じ方が複数ある部品の後始末をどこに置くかという設計で、公開前に防げるようになります
✦縦長や横長の画像でも閉じるボタンが作品に重ならない配置を、自分のページへ写して、エージェントへの指示文に書けるようになります
Stripe による安全な決済 · いつでもキャンセル可能
✦

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

▣ アプリ開発2026-10-20
PHP の API が no-store を返し、Cloudflare は素通しでした。エッジの TTL だけを上書きした設計メモ
壁紙アプリの一覧 API が no-store を返していたため、Cloudflare の前に置いても毎回オリジンまで届いていました。端末側の挙動は変えずにエッジの TTL だけを上書きする Cache Rule、効いたかを確かめる検査スクリプト、古い応答を出さないための「アップロードとパージを一組にする」手順までを書き残します。
▣ アプリ開発2026-10-20
文言を直したら、新しく足した字だけが豆腐になりました。日本語 Web フォントのサブセットに欠け字チェックを足す手順
日本語の文言を一行直しただけで、新しく加えた字だけが豆腐(□)になることがあります。サブセット化したフォントに無い文字をファイルと行番号つきで洗い出し、1 文字でも欠けたら止める小さなスクリプトを、実際に動かした出力つきで書き残します。
▣ アプリ開発2026-10-15
immutable を付けた資産を同じ URL で差し替えたら、利用者のブラウザだけが古いままでした
長期キャッシュを付けた JS を同じ URL で差し替えても、CDN をパージしてもブラウザは取り直しません。同じ URL・no-cache・クエリ付き・内容ハッシュ名の 4 方式を Chromium で並べて比べ、ビルドの最後にハッシュを付ける完全なスクリプトまでをまとめました。