0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

overflow-x hidden で蓋をする前に — スマホの横スクロールをCIで検出する手順

0
Posted at

この記事でやること

スマホ幅で横スクロールが出たとき、body { overflow-x: hidden; } で塞ぐのは対症療法です。原因の要素は残ったままなので、次の実装でまた混入します。

この記事では、横スクロールの原因要素を特定し、その検出をPlaywrightでCIに載せて再発を止めるまでを手順で書きます。手作業のチェックを1回やるのではなく、以後ずっと自動で落とすのがゴールです。

手順1. まず原因要素を目で見つける

DevToolsのConsoleに貼るスニペットです。ビューポートより右にはみ出した要素を出力します。

document.querySelectorAll('*').forEach((el) => {
  if (el.getBoundingClientRect().right > document.documentElement.clientWidth) {
    console.log(el);
  }
});

出力された要素にマウスを乗せるとページ上でハイライトされるので、その場で原因にたどり着けます。1ページを手で調べるぶんには、これで十分です。

手順2. ハマりどころ:このスニペットは誤検知する

ところが、これをそのまま自動テストに持ち込むとCIが常に赤くなります。誤検知が多いためです。

検証用に、3パターンのはみ出しを含むHTMLを用意して確かめました。

<style>
  /* ① 本物:ページに横スクロールを発生させる */
  .real-overflow { width: 120%; }

  /* ② 親が overflow:hidden のカルーセル(正常な実装) */
  .carousel { overflow: hidden; }
  .carousel__track { display: flex; width: 3000px; }
  .carousel__item { width: 300px; }

  /* ③ position:fixed の装飾 */
  .decor { position: fixed; left: 100vw; width: 200px; }
</style>

ビューポート375pxで実行した結果がこちらです。

ページの横スクロール: scrollWidth 428 / clientWidth 375 → 発生している

検出: [ 'real-overflow', 'carousel__track', 'carousel__item', 'carousel__item', 'decor' ]

5件ヒットしましたが、横スクロールを実際に起こしているのは real-overflow だけです。

検出された要素 実際は 理由
real-overflow 真犯人 width: 120% でページ幅を超えている
carousel__track / carousel__item 問題なし 祖先の .carouseloverflow: hidden でクリップしている
decor 問題なし position: fixed はスクロール可能領域を広げない

スニペットが見ているのは「ビューポートより右にあるか」だけです。実際に横スクロールを生むかどうかは、祖先のクリッピングと position を見ないと判定できません

手順3. 除外条件を足して精度を上げる

足す除外は3つです。

  1. 祖先の overflow-xhidden / clip / auto / scroll なら、そこで切られるので除外
  2. position: fixed は除外
  3. 幅または高さが0の要素は除外
const findOverflowingElements = () => {
  const limit = document.documentElement.clientWidth;

  const isClipped = (el) => {
    for (let p = el.parentElement; p && p !== document.documentElement; p = p.parentElement) {
      const overflowX = getComputedStyle(p).overflowX;
      if (['hidden', 'clip', 'auto', 'scroll'].includes(overflowX)) return true;
    }
    return false;
  };

  return [...document.querySelectorAll('*')].filter((el) => {
    const rect = el.getBoundingClientRect();
    if (rect.width === 0 || rect.height === 0) return false;
    if (rect.right <= limit + 1) return false;   // +1 はサブピクセルの丸め許容
    if (getComputedStyle(el).position === 'fixed') return false;
    if (isClipped(el)) return false;
    return true;
  });
};

同じHTMLで実行すると、検出は real-overflow の1件だけになります。ここまで来れば自動化できます。

+1 のマージンは省略しないでください。transform: scale() や小数の幅指定でサブピクセルの丸めが起きると、実害のない0.5pxのはみ出しでCIが落ちます。

手順4. Playwrightのテストにする

横スクロールは特定の幅でだけ出るので、ビューポート幅を振って回します。

npm i -D @playwright/test
npx playwright install chromium
// tests/overflow.spec.ts
import { test, expect, type Page } from '@playwright/test';

const VIEWPORTS = [
  { name: 'sp-min', width: 320 },
  { name: 'sp', width: 375 },
  { name: 'tablet', width: 768 },
];

const PATHS = ['/', '/about/', '/blog/'];

const findOverflowing = (page: Page) =>
  page.evaluate(() => {
    const limit = document.documentElement.clientWidth;

    const isClipped = (el: Element) => {
      for (let p = el.parentElement; p && p !== document.documentElement; p = p.parentElement) {
        const overflowX = getComputedStyle(p).overflowX;
        if (['hidden', 'clip', 'auto', 'scroll'].includes(overflowX)) return true;
      }
      return false;
    };

    return [...document.querySelectorAll('*')]
      .filter((el) => {
        const rect = el.getBoundingClientRect();
        if (rect.width === 0 || rect.height === 0) return false;
        if (rect.right <= limit + 1) return false;
        if (getComputedStyle(el).position === 'fixed') return false;
        if (isClipped(el)) return false;
        return true;
      })
      .map((el) => ({
        tag: el.tagName.toLowerCase(),
        class: String(el.className || ''),
        right: Math.round(el.getBoundingClientRect().right),
      }));
  });

for (const vp of VIEWPORTS) {
  for (const path of PATHS) {
    test(`${vp.name}(${vp.width}px) / ${path}`, async ({ page }) => {
      await page.setViewportSize({ width: vp.width, height: 800 });
      await page.goto(path);
      await page.waitForLoadState('networkidle');

      const offenders = await findOverflowing(page);

      expect(offenders, `はみ出し要素:\n${JSON.stringify(offenders, null, 2)}`).toEqual([]);
    });
  }
}

expect の第2引数にメッセージを渡しておくと、失敗ログにどの要素が何pxはみ出したかがそのまま出ます。「どこかで横スクロールしています」では直せませんが、「div.hero__inner が 428px(ビューポート 375px)」なら直せます。

手順5. GitHub Actionsに載せる

name: layout
on: [pull_request]

jobs:
  overflow:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run build
      - run: npx playwright test tests/overflow.spec.ts

playwright.config.tswebServer にサーバー起動コマンドと baseURL を書いておけば、PATHS の相対パスがそのまま解決されます。これでPRごとに横スクロールが検査され、混入した瞬間にレビュー前で止まります。

知っておくべき限界

万能ではないので、取りこぼすケースを書いておきます。

  • 疑似要素(::before / ::after)のはみ出しは検出できないquerySelectorAll で取れないため
  • JSで後から挿入される要素waitForLoadState('networkidle') でも間に合わないことがある。明示的に要素を待つ
  • Webフォント適用でレイアウトが変わる場合は document.fonts.ready を待つと安定する
  • overflow-x: auto の内部のはみ出しは除外される。そこは横スクロールしてよい領域なので、多くの場合は妥当

網羅性を上げにいくより、まず実要素のはみ出しをCIで止めるほうが効果があります。実務で遭遇する横スクロールの大半は実要素が原因です。

まとめ

  • Consoleスニペットは「見つける」道具として優秀。ただし overflow: hidden の内側と position: fixed を誤検知する
  • 祖先のクリッピングとpositionを除外条件に足せば、真犯人だけが残る
  • 判定が安定したらPlaywrightで複数ビューポートに展開し、CIで落とす
  • overflow-x: hidden は原因を隠すだけで、直してはいない

DevTools側の検証手順(レスポンシブ・CSSの詳細度・キャッシュ・LCPの絞り込み)は Chrome DevToolsで仮説と検証を回す手順 にまとめています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?