Float and Flow Spacing
フロー/アウル方式の prose スペーシングが float した図版の位置を静かにずらす理由、逆向きの「後続要素をゼロにする」修正、そして代わりにリードを等しくする方法。
問題
Prose Heading Spacing のフローユーティリティは、すべてのギャップを後続の兄弟要素の margin-block-start に置きます。
.prose > * + * { margin-block-start: var(--flow-space, 1rem); }
.prose :where(h3) { --flow-space: 2rem; }これはブロック要素に対しては堅牢です。しかし float に対しては誤りであり、何の診断も出さずに失敗します。
float は自身の margin-block-start で位置が決まります。その border-box の上端は「前の兄弟要素の bottom margin edge + 自身の margin-block-start」に置かれます。一方、隣のブロックはそのブロック自身の margin-block-start で位置が決まります。両者の上端が揃うのは、この 2 つのリード(上マージン)が等しいときだけです。フロー戦略にはそれを保証する仕組みがありません。見出しは本文より意図的に大きなリードを持つため、見出しの横に float した図版はデフォルトでずれます。このずれは、見出しが罫線やボーダーを描くまで目に見えません。
左のカラムが揃っているのは偶然にすぎません。どちらのリードもデフォルトの 1rem だからです。隣の要素を見出しに変えると、見出しは 2 つのリードの差分だけ画像より下に落ちます。
魅力的に見える誤った修正
最初に思いつくのは「float がすでにギャップを空けているのだから、隣の要素をゼロにすればいい」という発想です。
/* Backwards — do not ship this */
.prose .figure + * { margin-block-start: 0; }これは隣の要素を前のブロックの下端まで引き上げますが、float はリード 1 つ分だけ下に残ったままです。結果、テキストは画像より上から始まります。ある本番サイトでは、まさにこのルールが「これで揃う」というコードコメント付きで出荷されていました。
解決策
片方をゼロにするのではなく、2 つのリードを等しくします。動かすのはfloat の側で、隣の要素に合わせます。隣の要素を引き上げてはいけません。そのリードこそが、この戦略全体が守ろうとしているセクションのリズムだからです。
| 構造 | リード | 対応 |
|---|---|---|
p → float → p | どちらもデフォルト | 何もしない。ゼロ化ルールがあれば削除する |
p → float → h3 | 見出しのリードが大きい | float が見出しのリードを採用する: .figure:has(+ h3) { --flow-space: 2rem } |
見出しが自身の padding-top の下に罫線を描く | 見出しのリード + インセット | float が リード + インセット を採用する。インセットは 1 つのカスタムプロパティで共有する |
前の兄弟要素が margin-block-end を持つ | ブロックとは相殺されるが float とは相殺されない | ゼロにする: > *:has(+ .figure) { margin-block-end: 0 } |
| ブレークポイント以上でのみ float する | それ未満では要素はブロック | すべての補正をそのメディアクエリにスコープする |
コード例
float が見出しのリードを採用する
フローユーティリティは、位置を決める要素自身から --flow-space を読み取ります。見出しが後続する場合に float 側へ設定すれば、float が下に移動して見出しに揃います。
.prose > * + * { margin-block-start: var(--flow-space, 1rem); }
.prose :where(h3) { --flow-space: 2rem; }
/* The float beside an h3 takes the h3's lead */
.prose .figure:has(+ :where(h3)) { --flow-space: 2rem; }リードは em ではなく rem で定義する
em はそれを使う要素のフォントサイズ基準で解決されます。14px の float と 16.8px の h3 では同じ 2em が異なるピクセル値になるため、「修正」後もリードは等しくなりません。このページの例のように、リードの値は rem か固定単位のトークンで定義してください。
内側にインセットを持つ見出し
自身の padding-top の後に罫線を描く見出しは、ボックスの上端ではない線に揃えて見えます。float は リード + インセット を採用しなければなりません。インセットを共有のカスタムプロパティに引き上げておけば、見出しと float のルールが食い違うことはありません。
.prose {
--heading-lead: 2rem;
--heading-inset: 0.75rem; /* padding above the heading's rule */
}
.prose :where(h3) {
--flow-space: var(--heading-lead);
padding-top: var(--heading-inset);
}
.prose :where(h3)::before {
content: "";
display: block;
border-top: 2px solid currentColor;
}
/* Float top meets the rule, not the heading's box top */
.prose .figure:has(+ :where(h3)) {
--flow-space: calc(var(--heading-lead) + var(--heading-inset));
}float はマージンの相殺をしない
前の兄弟要素が持つ末尾の margin-block-end は、次のブロックのリードとは相殺されます。しかし、その間にある float とは相殺されません。float は両方の合計分だけ押し下げられ、横のテキストはそうなりません。float の前にある要素の末尾マージンはゼロにしましょう。
/* An hr that owns its own bottom margin */
.prose hr { margin-block: 1.5rem; }
/* Trailing margins must not reach a float */
.prose > *:has(+ .figure) { margin-block-end: 0; }より根本的な不変条件は、フロースペーシングのコンテナ内ではどの子要素も末尾マージンを持つべきではないということです。内側の視覚的な余白が必要なら、代わりに padding-block-end を使います。
補正を float のメディアクエリにスコープする
ブレークポイント以上でのみ float する図版は、それ未満では全幅のブロックです。その状態では図版自身の padding-block-end がギャップを作るので、後続要素のリードをゼロにするのが正しい対応になります。float の補正はすべて、float を有効にするのと同じメディアクエリの中に置きます。
.figure { padding-block-end: 1rem; }
/* Block mode: the figure's padding is the gap */
.figure + * { margin-block-start: 0; }
@media (min-width: 480px) {
.figure { float: left; width: 40%; margin-inline-end: 1.5rem; }
/* Float mode: restore the lead and equalise */
.figure + * { margin-block-start: var(--flow-space, 1rem); }
.figure:has(+ :where(h3)) { --flow-space: 2rem; }
.prose > *:has(+ .figure) { margin-block-end: 0; }
}診断
目視では判断できません。折り返された段落の横にある 12px のずれは、光学的な調整のように見えてしまいます。計測しましょう。
const float = document.querySelector('.figure');
const neighbour = float.nextElementSibling;
neighbour.getBoundingClientRect().top - float.getBoundingClientRect().top; // want 0コンテンツが生み出すすべての構造パターンで実行してください。p → float → h3、h2 → float → p、hr → float → dl などです。見出しがボックス内側に罫線を描く場合は、見出しではなく罫線の要素を計測します。このページを書くきっかけになった本番サイトでは、修正前は 53 個の float にわたる 14 パターンがすべてずれており、その方向も両側(+36px から −34px)に及び、揃っているものは 1 つもありませんでした。修正後は 14 パターンすべてで Δ = 0 を計測しました。
クイックリファレンス
| シナリオ | テクニック |
|---|---|
| デフォルトリードのブロックの横に float | ルール不要。.float + * { margin-block-start: 0 } があれば削除する |
| リードが大きい見出しの横に float | .float:has(+ h3) { --flow-space: <見出しのリード> } |
見出しが padding-top の下に罫線を描く | float 側に --flow-space: calc(var(--heading-lead) + var(--heading-inset)) |
前の兄弟要素が margin-block-end を持つ | > *:has(+ .float) { margin-block-end: 0 } |
| ブレークポイント以上でのみ float | すべての補正をその @media 内に置く。それ未満ではゼロ化したリードを維持する |
| float がコンテナの最初の子要素 | :first-child { margin-block-start: 0 } が float のリードを打ち消す。この構造を避けるか、個別に対応する |
| 検証 | パターンごとに neighbour.getBoundingClientRect().top - float.getBoundingClientRect().top === 0 |
AI がよくやるミス
float の後で隣の要素のリードをゼロにする。 「float がギャップを空けた」ように読めますが、テキストが画像より上に移動します。リードはゼロではなく等しくしなければなりません。
見出しを引き上げて float に合わせる。 見出しの大きなリードはセクションのリズムです。代わりに float を下に動かします。
見出しのボックス上端を揃える対象とみなす。 罫線が
padding-topの下にある場合は罫線に揃えます。float のリードにインセットを加えてください。インセットを 2 箇所にハードコードする。 見出しの
padding-topと float のcalc()は次のリデザインで食い違います。1 つのカスタムプロパティを共有しましょう。共有するリードを
emで書く。 float と見出しはフォントサイズが異なるため、2emはそれぞれ別のピクセル数になります。--flow-spaceの値にはremか固定トークンを使います。マージンの相殺が float にも適用されると思い込む。 前の兄弟要素の
margin-block-endは float の位置をずらしますが、横のブロックはずらしません。float の前の末尾マージンはゼロにするか、そもそもフローの子要素に末尾マージンを持たせないでください。float の補正をすべてのビューポートに適用する。 float のブレークポイント未満では要素はブロックであり、補正は誤りになります。メディアクエリにスコープしてください。
揃っているかを目で判断する。 コンテンツが生み出すすべてのパターンで
getBoundingClientRect().topの差を計測してください。
使い分け
float が隣の要素のリードを採用する
フロースペーシングのコンテナが float した子要素を許容する場合は常に使います。float した図版、プルクオート、サイドバーを含む markdown 記事などです。見出しのリズムを保てる唯一の補正です。
float の前の末尾マージンをゼロにする
フローコンテナ内に、まだ margin-block-end を持つ要素(サードパーティのコンポーネント、hr、グリッドなど)がある場合に使います。より望ましいのは、末尾マージンを完全に取り除き、すべての子要素で不変条件が成り立つようにすることです。
後続要素のリードをゼロにする
レスポンシブな図版の非 float(ブロック)モードでのみ使います。そこでは図版自身の padding-block-end がギャップになります。float モードでは決して使いません。
参考リンク
Prose Heading Spacing — このページが float 向けに補正するフローユーティリティ
Axiomatic CSS and Lobotomized Owls — Heydon Pickering, A List Apart