注意
この記事はAIの助けを借りていますが、内容は大規模Webアプリでの実体験に基づいています。
1. 問題:「あれ、マウスポインタどこに飛んでった?」
こういうシチュエーション、めっちゃ心当たりあるんじゃないでしょうか?
-
シチュエーション1: カードの右上に小さな「×」ボタンを置きたい。自信満々で
position: absolute; top: 0; right: 0;を書いた……と思ったら、ボタンがブラウザの天辺まで吹っ飛んで、カードだけポツン。なにこれ? -
シチュエーション2: モーダルポップアップを
position: fixedで作って、画面のど真ん中に固定しようとした。なのにスクロールしたらモーダルも一緒にスーッと動いちゃう。「fixed(固定)」のハズじゃなかったの? -
シチュエーション3: イライラ度MAX。ドロップダウンメニューに
z-index: 99999を吠えたのに、それでもz-index: 1のヘッダーの下に隠れちゃう。なんで?
正直に言ってください: みんな position をなんとなく「おまじない」で覚えてませんか?バグが出たら闇雲に position: relative をばらまいて、z-index を無駄に積み上げて、ダメなら最後の手段 !important で祈るだけ。
そんな曖昧なコーディング、今日で終わりにしましょう。ポジショニングの「手口」をぜんぶ暴露します。もう手探りじゃない、驚きもない!
2. 本質:「通常フロー」と、それを破るやつら
前回の記事(通常フロー)で、普通の要素は自然な「流れ」に沿ってるって話をしました。ブロック要素は縦に、インライン要素は横に並ぶっていう流れです。
でも position は、その流れを思い通りに操るためのパワーをあなたに与えてくれます。
-
static(デフォルト):一番マジメなやつ。常に流れに大人しく従い、決して枠を破らない。 -
relative:二重人格者。肉体はちゃんと流れの中に場所を取ってる(周りの要素は場所を空けてくれる)けど、魂はちょっと左や右にフワッと動ける。 -
absolute/fixed:自由の戦士たち。正式に通常フローから住所抹消。別次元に飛び立つ。周りの要素は彼らが存在しないかのように振る舞い、自動的に場所を詰める。彼らがどこへ飛ぶかは、「支柱」(包含ブロック)がどこにあるか次第。 -
sticky:へばりつき男。普段はrelativeみたいにフローに素直にいるけど、スクロールしてあるポイントに達すると、その場に根を張ってピタッと動かなくなる。
この呪文を暗記して:「フローを抜け出す」ってのがカギ。 absolute なブロックが他のテキストをガッツリ覆い被さっても、それはバグじゃなくて仕様。だって別次元に移動したんだから、下の要素たちには見えてないし避けようがない!
3. position 5兄弟の正体を暴く
3.1. static – 透明人間
- すべてのHTML要素のデフォルト。
top、left、right、bottom、z-indexを塗りたくりしても、全部無視。とにかく無害。
3.2. relative – 場所はキープしつつコッソリ移動
- 元の位置の土地はちゃんと占拠したまま(他の要素は侵入できない)。
-
top/leftで好きに動かせる。残した空白はずっとそこに。 -
最重要秘密:
relativeを使うときの90%は、自分を動かしたいからじゃなくて、子のabsoluteがしがみつく「縄張り(包含ブロック)」を作るため。
3.3. absolute – 無軌道な自由人
- フローから完全に抜け出す。土地は取らない。
- 包含ブロックを作る最も近い祖先を探す。多くの場合は
positionがstatic以外の要素だが、transform、filter、containなどが基準になる場合もある。 -
キラーパターン: 直近の親に
position: relativeを付け忘れる。するとabsoluteな子は行き場を失って、最終的にページのルートにへばりつく。そしてあなたの「×」ボタンは空の彼方へ飛んでいく。
.parent {
/* position: relative を付け忘れた… */
}
.child {
position: absolute;
top: 0;
right: 0;
/* 初期包含ブロック(通常はビューポート)を基準にしちゃう! */
}
3.4. fixed – 画面に釘付け(ただし誰かに邪魔されなければ)
- こちらもフローから脱出。
- ブラウザのウィンドウ(ビューポート)に直接へばりつく。スクロールしまくってもそこに突っ立ってる。ヘッダーや「トップに戻る」ボタンに最適。
-
落とし穴: ある日、この
fixed要素をtransformやfilterやcontain: paintが付いた親の中に入れたら……ゲームオーバー。その親が勝手に包含ブロックの権利を奪ってしまう。あなたのfixedはスクロールに一緒に引きずられる。まるで悪ふざけみたいに。
3.5. sticky – ずる賢いフェンス
-
fixedと絶対に混同しないで。stickyはもっと賢い。 - 最初はフローに素直にいる。でもスクロールして指定した位置(例:
top: 0)にくると、そこで岩のように固まる。 - ただしこの固まり方は、スクロールコンテナの範囲内でのみ機能する。その境界に達すると、それ以上は移動できなくなる。
4. 包含ブロック – ポジショニング宇宙の「へそ」
ここだ、ここがみんながスルーしがちなラスボス。
簡単に言うと、包含ブロックとは absolute や fixed が top、left や width: 100% を計算するときに使う「見えないボックス」のこと。彼らのサイズはこのボックスに完全に依存する。
どうやってそのボックスを見つけるの?
この宝の地図を見てください:
簡単にまとめると:
absolute を使うなら、基準はたいてい position: relative を持った先祖です。
でも世の中は複雑で、position 以外にも勝手に包含ブロックになりたがるやつらがいる:
-
transformがついた要素 -
filterがついた要素 perspective-
contain: paint、contain: layout will-change: transform
これを押さえれば、ポジショニングに関するバグの90%はニコニコしながら直せます。
top、left、% はどう動く?
たった一つのルールを覚えておきましょう:
-
top/bottom= 包含ブロックの高さに従う -
left/right= 包含ブロックの幅に従う
例えば top: 10% は、「包含ブロックの高さの10%分だけ上から離れた位置」という意味。絶対にズレたりしない。
5. Z-index と 重ね合わせコンテキスト – 階級闘争
z-index: 9999 を叩き込めば最前面に来ると思ってる?そんなに甘くない。
ようこそ 重ね合わせコンテキスト の世界へ。これは「戸籍制度」みたいなものです。もしあなたの親が z-index: 1 の貧乏カードしか持ってなかったら、自分が z-index: 999999 を積もうが、隣の家の z-index: 2 を持ったやつに軽々踏み潰されます。家の中では親が一番でも、外に出ればルールに従わなきゃいけない。
さらに悪いことに、z-index だけが階級を作るわけじゃない。opacity: 0.99 や transform、filter が付いた要素も、勝手に新しい重ね合わせコンテキストを生み出し、既存の z-index 秩序をぶち壊します。
血みどろの例:
<div style="position: relative; z-index: 1;">
<div style="position: absolute; z-index: 9999;">上の人たち、うるさいよ!</div>
</div>
<div style="position: relative; z-index: 2;">俺が上に乗ってやる、文句ある?</div>
見ましたか?子が 9999 で叫んでも、親の z-index: 2 の隣人が平然とその上に鎮座してる。次に z-index: 9999 が効かないからって、さらに 9 を増やす前に、親要素を探しましょう。
※重ね合わせコンテキストは深いので、別の記事で詳しく扱います。
6. React で実際に手を動かしてみよう – 百聞は一見に如かず
6.1. モーダル – fixed のアイドル(使い方次第)
超有名ユースケース:画面のど真ん中にポップアップを表示して、背面を暗くしたい。
fixed を使った解決策:
※ 実は現場では ReactDOM.createPortal() でモーダルを <body> 直下にぶち込むことが多いです。親の transform や z-index 地獄を避けるため。でも以下は純粋にロジックを理解するためのコードです。
import React, { useEffect } from 'react';
import './Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
// モーダル開いてる間は背面スクロールをロック
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>✕</button>
{children}
</div>
</div>
);
};
.modal-overlay {
position: fixed; /* こんにちはビューポート、ここにへばりつくよ */
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000; /* 全部の上に */
}
.modal-container {
background: white;
border-radius: 8px;
padding: 20px;
min-width: 300px;
max-width: 90%;
position: relative; /* 閉じるボタン(absolute)のための巣窟 */
}
.modal-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
}
6.2. ドロップダウンメニュー – relative が absolute を抱きしめる時
どうやってボタンの真下にメニューをピタッと出すか?簡単!
import React, { useState } from 'react';
import './Dropdown.css';
export const Dropdown: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div
className="dropdown"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<button className="dropdown-trigger">メニュー ▾</button>
{isOpen && (
<ul className="dropdown-menu">
<li>プロフィール</li>
<li>設定</li>
<li>ログアウト</li>
</ul>
)}
</div>
);
};
.dropdown {
position: relative; /* 包含ブロックの縄張り宣言 */
display: inline-block;
}
.dropdown-menu {
position: absolute;
top: 100%; /* 親の高さの100%分だけ下へ(ボタンのすぐ下) */
left: 0;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px 0;
min-width: 160px;
z-index: 10;
list-style: none;
}
.dropdown-menu li {
padding: 8px 16px;
cursor: pointer;
}
.dropdown-menu li:hover {
background: #f0f0f0;
}
6.3. 「トップに戻る」ボタン – fixed の基本形
import React, { useState, useEffect } from 'react';
import './BackToTop.css';
export const BackToTop: React.FC = () => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const toggleVisibility = () => {
if (window.scrollY > 300) {
setVisible(true);
} else {
setVisible(false);
}
};
window.addEventListener('scroll', toggleVisibility);
return () => window.removeEventListener('scroll', toggleVisibility);
}, []);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<button
className="back-to-top"
onClick={scrollToTop}
style={{ display: visible ? 'flex' : 'none' }}
>
↑
</button>
);
};
.back-to-top {
position: fixed;
bottom: 20px;
right: 20px;
width: 50px;
height: 50px;
border-radius: 50%;
background: #007bff;
color: white;
border: none;
font-size: 24px;
cursor: pointer;
align-items: center;
justify-content: center;
z-index: 99;
}
7. チェックリスト:もう position に泣かされないために
「なんで動かないんだ!」と頭をかきむしる前に、この5ステップを唱えてみて:
-
position書き忘れてない? 大量のz-indexやtopを書く前に、position: relative/absoluteを指定しないと全部無意味。デフォルトはstaticだよ。 -
正しい家(包含ブロック)を見つけられた?
absoluteが迷子になったら、親にrelativeが付いてるか確認。fixedが画面にへばりつかないなら、先祖にtransformなどの悪さをしてるやつがいないか探す。 - z-index リンチに遭ってない? 数字を増やす前に、親要素が低い重ね合わせコンテキストを持ってないかチェック。隣の親より弱いならどうしようもない。
-
width: 100% が予想外にでかくない?
absoluteな要素の%サイズは、包含ブロックのサイズに従う。外側の直近の親じゃないからね。 -
sticky に過剰な期待してない?
stickyはfixedじゃない。親要素(スクロールコンテナ)の範囲内でしかへばりつかない。土地がなくなれば一緒に流れる。
8. まとめ
position はブラックマジックじゃありません。必要なのはただ一つの真実を理解することです:通常フローを抜け出したら、自分の基準系(包含ブロック)がどこにあるのかを常に把握しなさい。
その「錨」を掴めば、笑っちゃうようなレイアウトバグは全部解決できます。もうおまじない頼りはやめましょう!
さらに極めるための資料:
次回予告
👉 【Frontend CSS – パート8】Flexboxの内部アルゴリズム – ブラウザはフレックスアイテムをどう計算するのか?
