原因はいつも祖先にある
一言でいうと
fixedが画面に固定されず、stickyがついてこないとき、原因はその要素ではなく祖先にあります。包含ブロックとスクロールコンテナーを誰が作ったのかを探せば、答えが出ます。
なぜ必要なのか
この2つのバグは、症状が原因をまったく指し示しません。
- モーダルのオーバーレイに
position: fixed; inset: 0を指定したのに、画面全体を覆わず、カードの中にだけ敷かれます - 目次に
position: sticky; top: 64pxを指定したのに、スクロールするとそのまま上がっていってしまいます
どちらも、その要素のCSSは完璧に正しいです。開発者ツールでその要素だけを見ても、何も問題がありません。そのため、人は値を変えながら何時間も使います。見るべき場所は祖先です。
どう動くのか: 包含ブロック
top、left、width: 50%のような値は、すべて包含ブロック(containing block)を基準に測ります。その包含ブロックが何なのかは、positionの値が決めます。MDNの包含ブロックのドキュメントが、ルールを整理しています。
| position | 包含ブロック |
|---|---|
static・relative |
最も近いブロックの祖先のcontent領域 |
absolute |
最も近い位置指定された(staticではない)祖先のpadding領域 |
fixed |
ビューポート |
sticky |
フローの基準は親、固定される基準は最も近いスクロールコンテナー |
ここまでは、たいていの人が知っています。問題は、その先です。
fixedを閉じ込める属性
祖先のどれか1つでも次の属性を持つと、その祖先がfixedとabsoluteの新しい包含ブロックになります。
transform (none 이 아닌 값)
filter
backdrop-filter
perspective
will-change: transform | filter | perspective
contain: layout | paint | strict | content
そのため、こういうことが起きます。カードにマウスを重ねたときに少し浮き上がる効果を付けようとして、transform: translateY(-2px)を入れます。あるいは、パフォーマンスのためにwill-change: transformを入れます。その瞬間、カードの中にあったモーダルのオーバーレイがカードの中に閉じ込められます。アニメーションとモーダルは無関係に見えるため、原因を見つけるまでに時間がかかります。
CSS Positioned Layout 3とCSS Containment 3が、それぞれの規定を載せています。
stickyが固定されない2つの理由
position: stickyは、2つの条件がどちらも合って初めて動作します。
1つ目は、しきい値が必要なことです。top、right、bottom、leftのうち1つ以上がautoではない必要があります。1つもなければ、固定される線がないので、そのまま流れていきます。エラーも警告もありません。
2つ目は、スクロールコンテナーが実際にスクロールされる必要があることです。stickyは、最も近いスクロールコンテナーの中でだけ固定されます。スクロールコンテナーは、overflowがvisibleではない最も近い祖先です。
ここでoverflow: hiddenが事故を起こします。横スクロールを防ぐために、途中のコンテナーにoverflow-x: hiddenを入れると、overflow-yも一緒にautoになり、そのボックスがスクロールコンテナーになります。ユーザーはそのボックスをスクロールせず(ページをスクロールします)、stickyが固定される場面が永遠に来ません。
そして、stickyが固定される範囲は、親ボックスの中です。親が自分の高さ分しかなければ、その分だけついてきます。「少しついてきて終わる」という症状は、ほとんどいつもこれです。
z-indexはどのボックスの中で競うのか
z-index: 9999を指定したのに、背後に敷かれてしまうことがあります。数字が小さいからではなく、別のスタッキングコンテキストの中にあるからです。
スタッキングコンテキストは、次のようなものが作ります。MDNのスタッキングコンテキストのドキュメントが、全体の一覧を載せています。
positionがstaticではなく、z-indexがautoではない要素position: fixedとposition: sticky(z-indexと無関係に、常に)opacityが1未満transform、filter、perspective、isolation: isolate、contain: paint
祖先がスタッキングコンテキストを作ると、その中のz-indexはそのコンテキストの中でだけ通用します。親がz-index: 1で兄弟がz-index: 2なら、親の中の子が9999でも、その兄弟の下になります。opacity: 0.99のような何気ない1行が、これを作ります。
現場での姿
3つのことが、繰り返し出てきます。
1つ目は、ドロップダウンが親に切り取られることです。祖先にoverflow: hiddenがあり、ドロップダウンがabsoluteです。fixedに変えればよいのですが、その祖先にtransformがあると、fixedも閉じ込められます。根本的な解決は、メニューをドキュメントの最上部へ移すこと(ポータル)です。
2つ目は、モバイルでだけヘッダーが固定されないことです。狭い画面で横方向のはみ出しを防ぐために、overflow-x: hiddenを入れたメディアクエリがあったのです。
3つ目は、iOSでだけおかしいことです。これはたいてい、-webkit-overflow-scrollingやセーフエリア(env(safe-area-inset-*))の問題ですが、ここで扱うルールとは別の軸なので、実機で確認する必要があります。推測で直さないほうがよいです。
診断の順番を覚えておけば、ほとんどが5分以内に終わります。
- この要素の
positionは何か- 包含ブロックは何か: 祖先をたどって上がりながら、
transform系を探す- stickyなら、しきい値はあるか、スクロールコンテナーはどれか
- z-indexなら、祖先のうち誰がスタッキングコンテキストを作ったか
次のラボですること
決まった文書構造にスタイルシートを書き、包含ブロックとスクロールコンテナーを計算ツールで自分で探します。カードにtransformを入れるとオーバーレイがどう閉じ込められるか、祖先のoverflow: hiddenがstickyをどう無効にするかを、目で確認します。