この記事でやること
スマホ幅で横スクロールが出たとき、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
|
問題なし | 祖先の .carousel が overflow: hidden でクリップしている |
decor |
問題なし |
position: fixed はスクロール可能領域を広げない |
スニペットが見ているのは「ビューポートより右にあるか」だけです。実際に横スクロールを生むかどうかは、祖先のクリッピングと position を見ないと判定できません。
手順3. 除外条件を足して精度を上げる
足す除外は3つです。
- 祖先の
overflow-xがhidden/clip/auto/scrollなら、そこで切られるので除外 -
position: fixedは除外 - 幅または高さが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.ts の webServer にサーバー起動コマンドと 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で仮説と検証を回す手順 にまとめています。