自分のサイトの課金ページをスマホ幅(375px)で開いたとき、ヘッダー右端のテーマ切替とメニューが画面の外へ出ていました。
計測すると document.documentElement.scrollWidth が 465px。画面幅より 90px 広い、つまり 1.24 倍(465÷375)に伸びたページです。決済へ進む課金ページでメニューが画面外に出るのは、コンバージョンに直結する不具合でした。
要素を全部走査して、右端が画面外に出ているものを探しました。見つかったのは 0 件でした。
犯人は、先頭の節に掛けていた装飾の ::after でした。幅 540px の金色の光です。疑似要素は querySelectorAll にも getBoundingClientRect にも現れません。
ここからは、同じ症状を自分のページで再現して、疑似要素まで含めて原因を一つに確定する観測スクリプトを書きます。直し方も三通り比べました。overflow-x: hidden で直ったように見えて、別の場所で sticky が壊れる落とし穴があります。
再現用の最小ページを用意する
本番ページをそのまま触る前に、同じ構造を 20 行ほどで再現しました。「全ページの先頭の節」に装飾を掛ける書き方が、今回の発端です。
<!doctype html><html lang="ja"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body{margin:0;font:16px/1.7 sans-serif}
header{display:flex;justify-content:space-between;align-items:center;padding:12px 15px;border-bottom:1px solid #ccc}
header button{width:40px;height:40px}
main>div{padding:0 15px}
section{position:relative;padding:24px 0}
/* 全ページの「先頭の節」に掛かる装飾 */
main > div > section:first-child::after{
content:"";position:absolute;top:0;left:50%;width:540px;height:200px;
transform:translateX(-50%);background:radial-gradient(closest-side,gold,transparent);
pointer-events:none;z-index:-1}
</style></head><body>
<header><span>Site</span><button id="menu">≡</button></header>
<main><div>
<section><h1>Membership</h1><p>先頭の節です。</p></section>
<section><h2>Plan</h2><p>二つ目の節です。</p></section>
</div></main></body></html>
節の幅は 345px(375px から左右の余白 15px ずつ)です。そこに幅 540px の光を中央寄せで置くと、右端は 345 の中心 187.5 に 270 を足した 457.5px に届きます。
ヘッドレス Chromium(375×800)で測ると、scrollWidth は 458px でした。本番の 465px とは数字が違いますが、節の幅や余白が違うためで、構造は同じです。
要素の走査は 0 件を返す
最初に誰もが書くのは、右端が画面外に出ている要素を探すループだと思います。
const vw = document.documentElement.clientWidth;
[...document.body.querySelectorAll('*')]
.filter(el => el.getBoundingClientRect().right > vw + 0.5);
// → [](再現ページでも本番でも 0 件)
::after は DOM の要素ではないため、このループには一度も登場しません。「はみ出している要素はありません」という結果が返り、調べる側は原因が見つからないまま止まってしまいます。
私自身、ここが一番時間を食う場所でした。結果が空のとき、私は「測り方が悪い」と疑う前に、「そもそも走査の対象にいない何かがある」と疑う順番に変えました。
疑似要素まで拾う観測スクリプト
疑似要素の情報は getComputedStyle(el, '::after') から読めます。幅や位置は取れますが、右端の座標までは取れません。そこで、候補を一つずつ display: none にして scrollWidth が縮むかどうかを見る、差分方式にしました。
// 375px で横にはみ出す原因を、疑似要素まで含めて探す観測スクリプト
(() => {
const vw = document.documentElement.clientWidth;
const sw = () => document.documentElement.scrollWidth;
const base = sw();
const out = { viewport: vw, scrollWidth: base, overflow: base - vw, elements: [], pseudos: [], confirmed: [] };
if (base <= vw) return out;
// 1) 通常の要素: 右端が画面外に出ているもの
for (const el of document.body.querySelectorAll('*')) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.right > vw + 0.5) {
out.elements.push({ node: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).join('.') : ''), right: Math.round(r.right) });
}
}
// 2) 疑似要素: querySelectorAll にも getBoundingClientRect にも出てこないので computed style で拾う
const cand = [];
for (const el of document.body.querySelectorAll('*')) {
for (const p of ['::before', '::after']) {
const cs = getComputedStyle(el, p);
if (cs.content === 'none' || cs.display === 'none') continue;
const w = parseFloat(cs.width);
if (!(w > 0)) continue;
if (cs.position !== 'absolute' && cs.position !== 'fixed') continue;
cand.push({ el, p, width: w, left: cs.left, transform: cs.transform });
}
}
// 3) 候補を1つずつ消して scrollWidth の変化量で確定する(推測ではなく差分で言い切る)
let i = 0;
for (const c of cand) {
const key = 'probe' + i++;
c.el.setAttribute('data-' + key, '');
const st = document.createElement('style');
st.textContent = `[data-${key}]${c.p}{display:none !important}`;
document.head.appendChild(st);
const after = sw();
st.remove();
c.el.removeAttribute('data-' + key);
out.pseudos.push({
host: c.el.tagName.toLowerCase() + (c.el.className && typeof c.el.className === 'string' ? '.' + c.el.className.trim().split(/\s+/).join('.') : ''),
pseudo: c.p, width: c.width, left: c.left,
scrollWidthWithout: after, delta: base - after
});
if (base - after > 0) out.confirmed.push(`${c.el.tagName.toLowerCase()}${c.p} を消すと ${base}→${after}`);
}
return out;
})()
このスクリプトは、ブラウザの開発者ツールのコンソールに貼ればそのまま動きます。再現ページでの出力は次のとおりでした。
{
"viewport": 375, "scrollWidth": 458, "overflow": 83,
"elements": [],
"pseudos": [{"host":"section","pseudo":"::after","width":540,"left":"172.5px","scrollWidthWithout":375,"delta":83}],
"confirmed": ["section::after を消すと 458→375"]
}
elements は空のまま、confirmed に「section::after を消すと 458 から 375 に戻る」と出ます。(モバイルエミュレーションで測ると 1px 多い 459 でしたが、結論は同じです。)「怪しい」ではなく「消したら戻った」まで言い切れるのが、この方式の良いところです。
候補の絞り込みで position が absolute か fixed のものだけにしているのは、static な疑似要素は通常のフローに乗って親の幅に収まりやすいからです。インラインの大きな疑似要素を疑う場合は、この条件を外してください。
なぜ clientWidth を使うのか
window.innerWidth ではなく document.documentElement.clientWidth を基準にしています。理由は、モバイルのエミュレーションで測ると分かります。
はみ出しがあると、モバイルでは innerWidth(レイアウトビューポート)が 459 に広がります。画面幅の 375 と比べるつもりで innerWidth を基準にすると、「はみ出し 0」と誤判定してしまいます。基準は clientWidth に固定しておくと安全です。
表の三行目も見落としやすい点です。body に overflow-x: hidden を足しても、モバイルでは innerWidth が 459 のままでした。横スクロールバーが消えるだけで、レイアウトビューポートの広がりは残っています。私は「見た目で出なくなった」ことと「幅が直った」ことを別に確認するようにしています。
直し方を五通り並べて測る
同じ再現ページに、直し方を一つずつ当てて scrollWidth と sticky の挙動を測りました。ページには、スクロールしても上端に張り付く目次(position: sticky; top: 0)を main の中に置いています。600px スクロールしたあとの目次の位置が 0 なら、sticky は生きています。
表から読み取れる二点
意外だったのは二点です。
一つ目は、main に overflow-x: hidden を掛けると sticky の目次が流れてしまうことです。スクロール後の位置は -347px でした。hidden はスクロールコンテナを作るため、中の sticky は main を基準にしてしまい、ページ全体のスクロールに追従しなくなります。clip はスクロールコンテナを作らないので、同じ 375px に収まっても位置は 0 のままでした。
二つ目は、html や body への指定では scrollWidth が 458 のまま動かなかったことです。ブラウザはルート要素の overflow をビューポートへ引き継ぐため、「はみ出し自体を消す」のではなく「スクロールを隠す」だけの動きになっていました。
直し方は二段にする
私の場合は、この結果から、overflow-x: clip を main に付けるのは保険、根本は装飾の対象を絞ることだと整理しています。本番運用では、まず clip を入れて症状を止め、そのあとで対象の絞り込みを入れる順番を推奨します。
/* Before: すべてのページの先頭の節に掛かる */
main > div > section:first-child::after { /* 540px の光 */ }
/* After: 装飾用のクラスを持つ節だけ */
main > div > section:first-child:has(.hero-glow)::after { /* 540px の光 */ }
/* 保険: スクロールコンテナを作らずにはみ出しだけ切る */
main { overflow-x: clip; }
はみ出しは隠す前に、出している場所を一つに確定します。 この順番を守ると、直したあとに何を確認すればよいかも決まります。
Playwright で毎回測れるようにする
一度直しても、次の装飾追加で同じことが起きます。デプロイ前に 375px で測るスクリプトを置いておくと、予防になります。
import json, sys
from playwright.sync_api import sync_playwright
probe = open('probe.js').read() # 上の観測スクリプトを保存したもの
url = sys.argv[1]
with sync_playwright() as p:
b = p.chromium.launch()
ctx = b.new_context(viewport={'width': 375, 'height': 800}, is_mobile=True, has_touch=True, device_scale_factor=2)
pg = ctx.new_page()
pg.goto(url)
r = pg.evaluate(probe)
print(json.dumps(r, ensure_ascii=False))
b.close()
sys.exit(1 if r['overflow'] > 0 else 0) # はみ出しがあれば終了コード 1
終了コードで落とすと、CI の一段として使えます。対象は、装飾が重なりやすい課金ページや一覧ページから順に増やしてください。
ここで一つ気をつけたいのは、sys.exit の前に出力を捨てないことです。結果を | head に渡すと、終了コードが消えて常に成功に見えます。この点はGrep の先頭 250 行の件でも触れた、「先頭だけ見て安心する」という同じ種類の落とし穴でした。
Claude Code に渡すときの書き方
観測結果があると、Claude Code への伝え方が変わります。「スマホで横にはみ出します」とだけ伝えると、修正の候補が推測で並びます。観測結果を添えれば、候補は一つに絞れます。
375px で scrollWidth が 465(clientWidth 375)です。
要素の走査は 0 件でした。疑似要素を一つずつ display:none にして
差分を取ると、main > div > section:first-child::after(幅 540px)を
消したときだけ 375 に戻ります。
この ::after が掛かる範囲を、装飾用クラスを持つ節だけに限定してください。
overflow-x: hidden は sticky が壊れるため使わず、保険が要るなら clip にします。
こう書いておくと、依頼の中に「確認済みの事実」「やってはいけない直し方」「保険の位置づけ」がそろいます。この形なら、同じ修正を何度も頼み直す往復を減らせるはずです。
スクリプトを経由して確かめる考え方は、pbxproj の編集境界をスクリプトに任せる話と同じ向きです。目で追うのではなく、同じ手順で何度でも同じ答えが返る形にしておく、という方針です。
落とし穴
innerWidth を基準にする — モバイルでは、はみ出しがあると innerWidth も一緒に広がります。基準は clientWidth です
- 疑似要素の
position を見ずに候補から外す — static でも幅の大きな疑似要素なら溢れることがあります。見つからないときは条件を外して再実行するのが対処になります
hidden で直したと判断する — 数値が 375 に戻ることと、sticky や position: fixed の子が無事なことは別です。スクロール後の位置も一緒に測ってください
html や body に指定して終わる — 本記事の再現ページでは scrollWidth が動きませんでした。ブラウザや構造で挙動が変わるので、自分のページで測ることをおすすめします
- 疑似要素の複数同時はみ出し — 一つを消しても
delta が 0 のままのとき、複数が同時に出ている可能性があります。その場合は、delta が 0 の候補をまとめて消して再計測してください
今回の再現ページは一つの疑似要素だけで組んでいます。複数が重なるページでは、まとめて消す手順を足す必要があるかもしれません。
次の一歩
自分のページを 375px で開き、コンソールに観測スクリプトを貼ってみてください。overflow が 0 なら今のところ安全です。0 を超えたら、confirmed に出た行が最初に見る場所になります。
その結果を一行だけ手元に残しておくと、次に装飾を足すときの比較になります。