---
title: "クリックを受け付けなかったドロップダウン"
description: "メニューは完璧に描画され、あるべき場所にぴったり収まり、そしてクリックをすべて飲み込みました。犯人は、本当には終わっていなかった登場アニメーションでした。CSS のスタッキングコンテキスト、アニメーションの fill-mode、そして目に見えないバグの追いかけ方を短くまとめます。"
url: "https://bookatu.com/ja-jp/blog/the-dropdown-that-refused-to-be-clicked"
language: "ja"
section: "ブログ"
category: "エンジニアリング"
author: "Bookatu エンジニアリングチーム"
published: "2026-07-11"
updated: "2026-07-11"
tags: ["css スタッキングコンテキスト", "アニメーション fill-mode", "z-index が効かない", "フロントエンド デバッグ", "css transform 重なり順"]
---

# クリックを受け付けなかったドロップダウン

メニューは完璧に描画され、あるべき場所にぴったり収まり、そしてクリックをすべて飲み込みました。犯人は、本当には終わっていなかった登場アニメーションでした。CSS のスタッキングコンテキスト、アニメーションの fill-mode、そして目に見えないバグの追いかけ方を短くまとめます。

> **要点**
>
> - animation-fill-mode: both は最後のキーフレームをいつまでも適用し続けます。そして transform が適用された要素は、恒久的なスタッキングコンテキストになります。
> - その結果、アニメーションしたコンテナの中にドロップダウンが閉じ込められます。z-index は、独自のスタッキングコンテキストを持つあとの兄弟要素とはもう張り合えません。
> - 最後のキーフレームを transform: none にしても助かりません。ブラウザは適用後の値を単位行列として計算し、それも transform として数えられます。
> - コンテナの登場アニメーションは both ではなく backwards で埋めます。自然な終了状態は、すでに最後のキーフレームと同じだからです。

クリックできないドロップダウンメニューをリリースしてしまいました。開くことは開きます。正しい位置に描画され、全体が見えていて、z-index も正しく、コンソールにエラーもありません。それでもクリックはメニューをすり抜け、下のパネルに届いてしまいます。まるでメニューが幽霊のようです。この手のバグと戦ったことがあるなら、あの気持ち悪さはご存じでしょう。見た目はすべて正しいのに、何ひとつ動かないのです。

## 本当に手前にいるのは誰なのか

最初のまともな答えは、コードを読むことではなくブラウザから返ってきました。document.elementFromPoint は、ある座標のクリックを実際に受け取る要素を教えてくれます。メニュー項目の中心を指定してみると、返ってきたのはページのもっと後ろにあるカードでした。メニューは見た目では手前にあり、実体としては奥にあったのです。

```js
// 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 context
for (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 として数えられます。スタッキングコンテキストは私たちの修正を生き延びました。

```css
/* 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 は本当に必要な隙間、つまり遅延のあるアニメーションが始まる前のフレームだけを埋めてくれます。アニメーションが終われば要素はそこから完全に切り離され、スタッキングコンテキストであることをやめます。よくできた登場アニメーションは要素の自然な状態で終わるので、見た目はまったく変わりません。

![暗い配色のダッシュボードで、すりガラス調のパネルより手前に正しく開いたドロップダウンメニュー](https://bookatu.com/blog/shots/stock-menu-open-dark.png)

*修正後です。メニューはすりガラスのパネルより手前に描画され、何より自分へのクリックをきちんと受け取ります。*

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

> アニメーションの長さは0.5秒でした。その副作用には終了時刻がありませんでした。
