昨日、自分のブログの編集画面で「公開設定へ」のボタンが押せなくて2回 空振りした。
セレクタでは見つかる。要素への参照も返ってくる。クリックを送っても、何も起きない。例外も出ない。
原因は offsetParent が null だったこと——ウィンドウ幅が狭くて、そのボタンが折りたたみメニューの中にいた。それ自体は「まあ、そういうこともある」で終わる話なのだが、気になったのは次のことだった。
では、「押せる」はどう判定するのが正しいのか。
offsetParent を見ればいいのか。getBoundingClientRect() の面積か。checkVisibility() か。どれも「よく使われている」が、どれが正しいのかを測った記憶がない。
そこで要素を隠す/届かなくする状態を27通り作り、可視判定を10通り実装して、本物のマウスクリックが届いたかと突き合わせた。ついでに「目に見えるか」も画素で測った。
結論から書くと、「見つかる」「見える」「押せる」は3つ別の問いで、片方の答えをもう片方に使うと外す。そして自分の検査ツール4本が、まさにそれをやっていた。
TL;DR
2026-10-09 の実測で分かったことを先に並べる。
- 「押せるか」を27状態すべてで当てられた判定は、10通りのうち1つだけだった。
elementFromPoint(中心)が自分か子孫 かつdisabledでない かつpointer-eventsがnoneでない、の組み合わせ(27/27) - 「見えるか」はどの判定でも満点にならなかった(最高 21/27)。
filter:opacity(0)は10通り全部が「見えている」と答えた。画素を見るしかない -
el.click()は27状態のうち26で成功する。人間が押せない18状態でもハンドラは動く - 矩形が
0×0の要素の「中心」は(0, 0)。座標クリックは消えずに画面左上の別のボタンに当たった(実測で「記事を削除」が押された) - 「とりあえず
scrollIntoViewしてから押す」で直るのは、押せなかった19件のうち1件 - 押す前に確かめる費用は 1件 0.02ミリ秒。ページを1枚開く時間の10万分の1
測定環境は Google Chrome 154.0.8037.98 / macOS 26.5(arm64)/ Python 3.9.6。コードと実測値は本文に全部 載せる。
目次
- 「押せる」の正解をどう作るか
- 27通りの隠し方と、判定10通り
- 「押せるか」は、満点が1つだけ出た
- 「見えるか」は、どの判定でも満点にならなかった
- el.click() は、ほぼ必ず「成功」する
- 空振りではなく、誤爆だった
- 崖は1pxごとに来る。測る幅のほうが間違っていることもある
- 自分のサイトに当ててみた
- 自分のコードを数えた
- 「とりあえずスクロールしてから押す」は、ほぼ効かない
- 作った道具
- 押す前に1回 確かめると、どれだけ変わるか
- まとめ
「押せる」の正解をどう作るか
まず正解を決めないと、判定の良し悪しが測れない。今回は2つ用意した。
正解①=本物のマウスイベントを送る
el.click() ではダメだ。あれはイベントを直接 要素へ投げるだけで、ヒットテストを通らない。MDN の HTMLElement.click() にもあるとおり、ユーザー操作のシミュレーションではない。
なので Chrome DevTools Protocol の Input.dispatchMouseEvent を使い、座標に対して本物の押下・解放を送る。これなら実ブラウザと同じ経路を通る。
def click(self, x, y):
"""本物のマウスクリック(ヒットテストを通る信頼されたイベント)。"""
for t in ("mousePressed", "mouseReleased"):
self.cmd("Input.dispatchMouseEvent",
{"type": t, "x": x, "y": y, "button": "left", "clickCount": 1,
"buttons": 1 if t == "mousePressed" else 0})
ページ側は、対象のボタンにハンドラを付けて発火を数えるだけでいい。
window.HITS = {};
document.querySelectorAll(".t").forEach(b => {
b.addEventListener("click", () => {
const k = b.dataset.case; HITS[k] = (HITS[k] || 0) + 1;
});
});
正解②=画素が変わるか
「見えるか」のほうは、要素のプロパティを見た時点で負けだ(後述するが、どのプロパティも外す)。なので Page.captureScreenshot でその要素の矩形だけをクリップ撮影し、対象を visibility: hidden !important にしてもう一度撮り、バイト列が変わるかで判定した。
remove() ではなく visibility にしたのは、レイアウトを一切 動かさないため。消すと隣が動いて、関係ない画素まで変わってしまう。
a = c.shot((x, y, w, h)) # 対象あり
c.ev("el.style.setProperty('visibility','hidden','important')")
b = c.shot((x, y, w, h)) # 対象なし
pixel_visible = (a != b)
検証ページは、1セルの大きさを固定して中身を絶対配置にした。こうすると対象を消しても隣のセルが1pxも動かないので、クリップ撮影の差分がそのまま「その要素の画素」になる。
27通りの隠し方と、判定10通り
隠し方
「隠す」と一口に言っても、やり方で挙動がまるで違う。実測で使ったのはこの27通り。
| # | 状態 | # | 状態 |
|---|---|---|---|
| s01 | 素のボタン(対照) | s15 | 祖先が inert
|
| s02 |
display:none(自身) |
s16 |
hidden 属性 |
| s03 | 祖先が display:none
|
s17 | 祖先が content-visibility:hidden
|
| s04 |
visibility:hidden(自身) |
s18 | 閉じた <details> の中 |
| s05 | 祖先 visibility:hidden + 自身 visible
|
s19 |
transform:scale(0)(自身) |
| s06 |
opacity:0(自身) |
s20 |
clip-path:inset(100%)(自身) |
| s07 | 祖先が opacity:0
|
s21 |
aria-hidden="true"(見た目は普通) |
| s08 | 祖先が 寸法0 + overflow:hidden
|
s22 | 祖先 overflow:hidden でスクロール位置の外 |
| s09 | 画面外(left:-9999px) |
s23 |
filter:opacity(0)(自身) |
| s10 | 透明なものに覆われている | s24 | 祖先が position:fixed(画面内) |
| s11 | 不透明なものに覆われている | s25 | 祖先が position:fixed で画面外 |
| s12 |
pointer-events:none(自身) |
s26 | 自身が position:fixed(画面内) |
| s13 | 祖先が pointer-events:none
|
s27 |
sr-only 定番(1px + clip:rect(0 0 0 0)) |
| s14 |
disabled 属性 |
s24 と s26 を分けてあるのが地味に効いた。理由は後で出てくる。
判定
よく見かける書き方をそのまま10通り実装した。
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const inVp = cx >= 0 && cy >= 0 && cx < innerWidth && cy < innerHeight;
const hit = inVp ? document.elementFromPoint(cx, cy) : null;
P1 = el.offsetParent !== null;
P2 = el.offsetWidth > 0 && el.offsetHeight > 0;
P3 = r.width > 0 && r.height > 0;
P4 = cs.display !== 'none' && cs.visibility === 'visible' && parseFloat(cs.opacity) > 0;
P5 = el.checkVisibility({ contentVisibilityAuto: true, opacityProperty: true,
visibilityProperty: true, checkVisibilityCSS: true,
checkOpacity: true });
P6 = !!(inVp && hit && (hit === el || el.contains(hit)));
P7 = !el.disabled && cs.pointerEvents !== 'none';
P8 = el.offsetParent !== null && r.height > 0; // うちの検査4本が使っている式
P9 = P5 && P6 && P7;
P10 = P6 && P7;
P8 は後ろめたい気持ちで入れた。自分のデザイン検査ツールが実際に使っている式だからだ。
「押せるか」は、満点が1つだけ出た
P10(ヒットテスト かつ 受付)だけが 27/27 で実クリックと一致した。
ヒットテスト単独では disabled を取りこぼす
P6 は 26/27。唯一 外したのが s14(disabled 属性)で、ここは中心点のヒットテストが自分自身を返す。見えているし、そこにいる。でもクリックしてもハンドラは動かない。
だから disabled と pointer-events は別に見る必要がある。逆に言えば、足すのはこの2つだけでよかった。
checkVisibility() を足すと、かえって悪くなる
直感的には「見えていて、そこにいて、受け付ける」が正しそうに思える。それが P9 で、実測は 25/27 だった。外したのは s06(opacity:0)と s07(祖先が opacity:0)。
透明な要素は、見えないが押せる。checkVisibility() に checkOpacity: true を渡すとこれを「見えない」と答えるので、押せるかの条件に混ぜると誤判定になる。
ここが今回いちばん腑に落ちた点で、「見えるか」を「押せるか」の必要条件にしてはいけない。別の問いなのだ。
offsetParent は、自分が position:fixed だと null になる
P1 は 10/27。誤って「押せる」と答えたのが16件、逆が1件。この逆の1件が s26(自身が position:fixed)だった。
CSSOM View の仕様にはっきり書いてある。要素の position が fixed のとき、offsetParent は null を返す。普通に見えていて、普通に押せるのに、だ。
s24(祖先が fixed)では offsetParent は非 null になる。自分が fixed のときだけ null。この1行の違いで、固定ヘッダの中のボタンが「存在しないこと」になる。
el.click() は、ほぼ必ず「成功」する
27状態のうち、el.click() でハンドラが動いたのは 26件。動かなかったのは s14(disabled)だけだった。
一方、本物のマウスクリックが届いたのは 8件。
つまり18状態で、自動化は「押せた」と報告するが、人間はそのボタンを押せない。display:none でも、透明なものに覆われていても、pointer-events:none でも、画面外でも、el.click() は通る。
E2Eテストで el.click() を使っていると、誰も押せないUIのままテストが緑になる。この型は「検査が出した NG 0件は合格ではなかった」で書いた偽陰性と同じで、実測で言えば偽陰性が18/27 ある道具を信じていることになる。
「見えるか」は、どの判定でも満点にならなかった
最高が 21/27。満点は無い。
filter:opacity(0) は10通り全部が見抜けなかった
s23 は、10通りの判定すべてが「見えている」と答え、画素は1つも変わらなかった唯一の状態だ。checkVisibility() は opacity プロパティは見るが、filter の中身までは見ない。
似たことが clip-path:inset(100%)(CSS Masking)でも起きる。こちらは checkVisibility() が「見えている」と答え、ヒットテストだけが正しく「当たらない」と答えた。
checkVisibility() は一方向にしか外さない
ただし P5 には良い性質がある。誤りは FP 9件・FN 0件——つまり「見えないのに見えていると言う」側にしか外さない。
ということは、除外用のふるいとしてなら安全に使える。checkVisibility() が false を返したものは、本当に見えていない。true を返したものだけ、さらに疑えばよい。
結局、画素を見るしかない
「見えるか」を要素のプロパティで答えるのは無理だった。ヒットテストと checkVisibility() は反対の向きに外すので、組み合わせても穴が残る。
これは副産物として、自分たちのデザイン検査の方針を裏づけてくれた。スクリーンショットを撮って目で見る工程は、手抜きの逆で唯一 正しい測り方だったわけだ。
空振りではなく、誤爆だった
ここからが今回いちばん怖かったところ。
矩形が 0×0 の要素の中心は (0, 0) になる。座標でクリックする実装だと、その点に別の何かがいる。クリックは消えない。
左上に「記事を削除」を置いたページで、幅を狭めて同じ手順を実行した実測がこれだ。自動化の側からは「#publish を見つけて、その中心をクリックした」としか見えない。実際に押されたのは削除ボタンだった。
例外も出ないし、空振りの報告も出ない。
角丸があると、矩形の角は要素の上に無い
ついでに測ったおまけ。border-radius: 8px のボタンでは、getBoundingClientRect() が返す左上の角と、そこから +1px の点は、要素の上に無い(elementFromPoint は html を返す)。+2px から当たるようになる。
ヒットテストはボックスの矩形ではなく、描かれた形に従う。「矩形の角を押す」実装は角丸で外す。中心を使うこと。
崖は1pxごとに来る。測る幅のほうが間違っていることもある
折りたたみの境目を max-width: 700px にしたページで、幅を変えながら同じボタンを押した。701px と 700px の間で、見つかり方は何も変わらないのに押せなくなる。
--window-size には下限がある
headless Chrome に --window-size=320,800 や 375,800 を渡しても、innerWidth は 500 になった。450 でも 500。500 以上なら指定どおりだった(2026-10-09 実測)。
つまり「375pxで再現しない」と言っていたとき、実際には500pxで測っていた可能性がある。狭い幅を作りたいなら Emulation.setDeviceMetricsOverride を使う。
viewport メタが無いページは980pxになる
そしてデバイスエミュレーションにも罠があった。<meta name="viewport"> が無いページに 375px を指定すると innerWidth は 981 になる。606px を指定しても 980。
仮想ビューポートの既定幅に落ちるので、狭いレイアウトが一度も出ない。自分で作った検証ページでこれを踏んで、しばらく「なぜメディアクエリが効かないのか」を眺めていた。
教訓は単純で、幅を指定したら innerWidth を読んで確かめる。
自分のサイトに当ててみた
ここまでは作った検証ページの話なので、本番の6ページに当ててみた。うちのデザイン検査4本(design_probe.js / typography_probe.js / design_sweep.js / bench_probe.js)が共通で使っている式がこれだ。
const vis = e => e.offsetParent !== null && e.getBoundingClientRect().height > 0;
取りこぼしは0件だった
position:fixed の要素を落とす欠陥は、6ページで1件も踏んでいなかった。固定ヘッダも固定CTAも置いていないからだ。
直す理由は今は無い。ただし置いた日に壊れるので、ここに書いて残しておく。
「見えないのに通した」101件は、全部 同じ原因だった
逆向きの食い違いが101件(クイズのページで81件、宅建のトップで20件)。原因を数えたら、101件すべてが閉じた <details> の中にあった。原因不明は0件。
閉じた <details> の中身は、offsetParent も非 null のまま、高さも持ったままになる。でも checkVisibility() は正しく false を返す(content-visibility 相当の扱いになるため)。
では検査結果は間違っていたのか——測ったら「いいえ」だった
ここで「じゃあ除外しよう」と手を動かしかけて、止めて測った。
<details> を開く前と後で、中の要素の矩形を全部 比べた。結果は130要素すべてが完全に一致。位置も寸法も1pxも動かない。375px でのNG件数も、閉じたまま測っても開いてから測っても同じだった。
つまり閉じた <details> の中は、レイアウトは生きていて、描画とヒットテストだけが止まっている。
だから寸法の検査(文字サイズ・タップ標的・行長)は閉じたままでも正しい。壊れるのは「押せるか」だけだった。危うく、正しく動いている検査から101要素ぶんの網を外すところだった。
自分のコードを数えた
この弱い判定が自分のリポジトリに何箇所あるかを数えた(2026-10-09 実測)。
| 数えたもの | 件数 |
|---|---|
.py / .js / .mjs(外部ライブラリを除く) |
2,529 |
offsetParent !== null を可視判定に使っているファイル |
16 |
| うち正本(検査ツール4本+別プロジェクト2本+不変条件テスト1本) | 7 |
elementFromPoint を使っているファイル |
0 |
残り9ファイルは、過去記事の図を検査するためのコピーだった。
そして .click() を呼んでいる自分のコードは1ファイル(16箇所)。中を見たら vis(e) { return e && !e.hidden && e.offsetParent !== null; } で可視を判定して el.click() で押していた。今日の基準でいうと、この不変条件テストは誰も押せないUIでも緑になる。
「とりあえずスクロールしてから押す」は、ほぼ効かない
自動化でよくやる保険が scrollIntoView だ。押せなかった19件に当てた実測はこうなった。
- 直ったのは1件(祖先の
overflowで画面の外に出ていたもの) - 直らなかったのが18件
- 逆に壊れたものは0件
残り18件は、display:none・覆われている・pointer-events:none・disabled・inert・閉じた <details>・clip-path など、スクロールとは無関係な理由だった。
スクロールは「悪くはないが、ほとんど効かない」。やるなとは言わないが、これで直ったことにするのが危ない。
作った道具
判定の結論(P10)と、押せないときの原因の名指しを1つにまとめた。
ページ内で使う
whynot('#publish')
// => { clickable: false, cssVisible: false, rect: [0,0,0,0], point: [0,0],
// hit: 'button#del',
// reasons: [ {code:'DISPLAY_NONE', where:'button#publish'},
// {code:'ZERO_RECT', where:'button#publish', detail:[0,0]},
// {code:'POINT_LANDS_ELSEWHERE', where:'button#del',
// detail:{text:'記事を削除'}} ] }
判定の本体はこれだけ。
const clickable = !!(inViewport && hitIsSelfOrDescendant
&& !el.disabled && cs.pointerEvents !== 'none');
原因のほうは、要素から祖先へ辿って最初に見つかったものを並べる。pointer-events と visibility は継承されるので、いちばん外側(原因を作った側)だけを残すようにした。これを入れないと、同じ理由が親子で2回 並ぶ。
コマンドラインから使う
$ python3 whynot.py "https://example.test/edit" '#publish' --mobile 700,800 --click
押せない #publish
矩形=[0, 0, 0, 0] 中心=[0, 0] ビューポート内=True CSS上の可視=False
中心点を受け取るのは button#del
実際にクリックを受け取った要素=button#del
- display:none button#publish
- 矩形が 0×0(中心が (0,0) になる) button#publish [0, 0]
- 矩形0のため中心点が無関係な要素の上 button#del {'text': '記事を削除'}
$ echo $?
1
終了コードは 0=押せる / 1=押せない(理由を出した)/ 2=判定できなかった(要素が無い・ページが開けない)の3つに分けた。1 と 2 を同じにすると、調べられなかったことを「押せない」と書くことになる。
27状態すべてで両方向テストした
道具の答えと本物のクリックを、27状態すべてで突き合わせた実測。
| 検査 | 結果 |
|---|---|
| 押せる/押せないの判定が実クリックと一致 | 27 / 27 |
| 押せないときに原因コードを正しく名指し | 27 / 27 |
| 期待と違ったもの | 0件 |
期待する原因コードは27状態それぞれに手で書いた(s18 なら DETAILS_CLOSED、s27 なら CLIP_LEGACY といった具合に)。判定が当たっていても理由が違えば落ちるようにしてある。直し方を間違えるのを防ぐためだ。
答えられないこと=ビューポートの外
ついでに分かった制約。ヒットテストは elementFromPoint なので、画面に入っていない要素には答えられない。
最初、本番ページで「押せる判定のズレ」が133件も出て驚いたが、その大半は単に画面外(スクロールしないと見えない位置)だった。各要素を scrollIntoView してから測り直したら、ズレは 0〜4件になった。
ページ全体を一度に監査したいなら、要素ごとにスクロールしてから測る必要がある。これはコストではなく、前提の違いだ。画面に出ていない文章を数えたときも同じところでつまずいている。
押す前に1回 確かめると、どれだけ変わるか
同じページ・同じ目的(「公開設定へ」を押す)を、8通りの幅で2つの手順で実行した。
- 手順①=見つけて、矩形の中心を押す → 目的を達成 4/8、誤って「記事を削除」を押した 4/8
- 手順②=押せるか確かめ、押せなければ折りたたみを開いてから押す → 目的を達成 8/8、誤って押したもの 0/8
手順②のコードは、ほぼこれだけだ。
r = ev("whynot('#publish')")
if not r["clickable"]:
click_center("#more") # 折りたたみを開く
r = ev("whynot('#publish2')") # メニューの中の同じボタン
if not r["clickable"]:
return "押せない(理由は r['reasons'])" # 押さずに終わる
click(*r["point"])
大事なのは最後の行で、押せないと分かったら押さない。「とりあえず送ってみる」をやめるだけで、誤爆が消えた。
費用は1件 0.02ミリ秒
診断そのものは、1件を単発で呼んで 0.3ミリ秒。あるページの a / button / summary 118件をまとめて回して 2.6ミリ秒(1件あたり 0.02ミリ秒)という実測だった。
ページを1枚 開くのに 2.3秒かかっているので、10万分の1だ。確かめない理由が思いつかない。
まとめ
最初に「offsetParent が null だった」と分かった時点で、自分は原因を突き止めた気になっていた。でもそれは症状であって、判定の正しさとは関係がなかった。
実測すると offsetParent は、「押せるか」で 10/27、「見えるか」で 12/27。つまりコインを投げるより少しマシな程度の判定を、検査ツール4本と不変条件テスト1本でずっと使っていた。日本語の折り返しを調べたときも、原因だと思っていたCSSプロパティは無関係だった。
そして <details> の件では、逆のことも起きた。食い違いを見つけた時点で「検査が間違っている」と決めて直しかけ、測ったら検査のほうが正しかった。
1行にすると、食い違いを見つけたら、どちらが間違っているかも測る。
次の1アクション
自分のコードから offsetParent で可視を判定している箇所を1つ探して、その要素が position:fixed になったときに何が起きるかだけ試してみてほしい。たぶん1分で終わる。
grep -rn "offsetParent" --include='*.js' --include='*.ts' . | grep -v node_modules
公開前のUIが「本当に操作できるか」を機械で測る仕組みづくりは、サイト監査(¥55,000/1サイト・50面まで)でも請けています。今回の27状態の検証ページと whynot 一式は、そのまま持ち込める形にしてあります。






