クリックを受け付けなかったドロップダウン
メニューは完璧に描画され、あるべき場所にぴったり収まり、そしてクリックをすべて飲み込みました。犯人は、本当には終わっていなかった登場アニメーションでした。CSS のスタッキングコンテキスト、アニメーションの fill-mode、そして目に見えないバグの追いかけ方を短くまとめます。
クリックできないドロップダウンメニューをリリースしてしまいました。開くことは開きます。正しい位置に描画され、全体が見えていて、z-index も正しく、コンソールにエラーもありません。それでもクリックはメニューをすり抜け、下のパネルに届いてしまいます。まるでメニューが幽霊のようです。この手のバグと戦ったことがあるなら、あの気持ち悪さはご存じでしょう。見た目はすべて正しいのに、何ひとつ動かないのです。
本当に手前にいるのは誰なのか
最初のまともな答えは、コードを読むことではなくブラウザから返ってきました。document.elementFromPoint は、ある座標のクリックを実際に受け取る要素を教えてくれます。メニュー項目の中心を指定してみると、返ってきたのはページのもっと後ろにあるカードでした。メニューは見た目では手前にあり、実体としては奥にあったのです。
// Who really gets the click at this point?const item = document.querySelector('[role="menuitem"]');const r = item.getBoundingClientRect();const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);console.log(top === item || item.contains(top)); // false -> something covers it // Walk the ancestors and list everything that creates a stacking contextfor (let el = item; el; el = el.parentElement) { const cs = getComputedStyle(el); const reasons = []; if (cs.transform !== 'none') reasons.push('transform'); if (cs.filter !== 'none') reasons.push('filter'); if (cs.backdropFilter !== 'none') reasons.push('backdrop-filter'); if (cs.opacity !== '1') reasons.push('opacity'); if (cs.position !== 'static' && cs.zIndex !== 'auto') reasons.push('z-index'); if (reasons.length) console.log(el.className, reasons);}祖先をたどっていくと見つかりました。メニューを含むヘッダーが transform を持っていると報告してきたのです。どのスタイルシートもヘッダーに transform を指定していないのにです。その近くにある transform といえば、ページ読み込みの0.5秒後には終わっているはずの、ふわりと浮き上がる登場アニメーションだけでした。
fill-mode はアニメーションより長生きする
animation-fill-mode: both は、アニメーションが始まる前は最初のキーフレームを保ち、終わったあとは最後のキーフレームを保ち続ける、という意味です。ずっと保ち続けます。どれかのキーフレームが transform をアニメーションしていれば、終わったあともその要素には transform が適用されたままで、transform のかかった要素はスタッキングコンテキストになります。子孫要素の z-index はその内側に閉じ込められ、同じ箱の中の兄弟としか競えず、ページの他の部分とは決して競えません。
私たちのページでは、ヘッダーのあとに backdrop-filter を使ったパネルが並んでいました。これも独自のスタッキングコンテキストを作ります。密閉された箱が二つあり、その間の描画順はツリーの順序で決まります。混乱しながら上げ続けていた z-index では決まりません。メニューの z-index が100万でも結果は同じでした。自分の箱の中では、外の誰にも声が届かないのです。
罠の中にはもう一つ罠がありました。最後のキーフレームを transform: none に変えれば、残る値は無害になるだろうと考えたのです。ところが計算後のスタイルは、依然として行列を返してきました。ブラウザは適用中のアニメーションの transform を行列値として解決し、単位行列であっても重なり順の判定では transform として数えられます。スタッキングコンテキストは私たちの修正を生き延びました。
/* BAD: the finished animation keeps applying its last keyframe forever. The header remains a stacking context for the life of the page. */.enter { animation: rise 0.5s ease-out both;} /* GOOD: fill backwards only. The element returns to its natural state after the animation, which is identical to the final keyframe anyway. */.enter { animation: rise 0.5s ease-out backwards;} @keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; }}書き留めたルール
コンテナの登場アニメーションは backwards で埋め、both は使わない。backwards は本当に必要な隙間、つまり遅延のあるアニメーションが始まる前のフレームだけを埋めてくれます。アニメーションが終われば要素はそこから完全に切り離され、スタッキングコンテキストであることをやめます。よくできた登場アニメーションは要素の自然な状態で終わるので、見た目はまったく変わりません。

- 自分の目より elementFromPoint を信じます。見た目の順序と当たり判定の順序は食い違うことがあり、クリックを受け取るのは片方だけです。
- z-index の辻褄が合わなくなったら、値を上げるのをやめてスタッキングコンテキストを探し始めます。祖先をたどる作業は1分で終わります。
- 終わったアニメーションが何を残していくかを点検します。静止したページで getComputedStyle を見れば、まだ何が適用されているか分かります。
- fill で残った transform も transform です。キーフレームが none と書いていても変わりません。
アニメーションの長さは0.5秒でした。その副作用には終了時刻がありませんでした。