zudo-css-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

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 した図版はデフォルトでずれます。このずれは、見出しが罫線やボーダーを描くまで目に見えません。

Flow Spacing Misaligns a Float Beside a Heading

左のカラムが揃っているのは偶然にすぎません。どちらのリードもデフォルトの 1rem だからです。隣の要素を見出しに変えると、見出しは 2 つのリードの差分だけ画像より下に落ちます。

魅力的に見える誤った修正

最初に思いつくのは「float がすでにギャップを空けているのだから、隣の要素をゼロにすればいい」という発想です。

/* Backwards — do not ship this */
.prose .figure + * { margin-block-start: 0; }

これは隣の要素を前のブロックの下端まで引き上げますが、float はリード 1 つ分だけ下に残ったままです。結果、テキストは画像よりから始まります。ある本番サイトでは、まさにこのルールが「これで揃う」というコードコメント付きで出荷されていました。

Anti-Pattern — Zeroing the Neighbour's Lead Puts Text Above the Image

解決策

片方をゼロにするのではなく、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.8pxh3 では同じ 2em が異なるピクセル値になるため、「修正」後もリードは等しくなりません。このページの例のように、リードの値は rem か固定単位のトークンで定義してください。

Fix — The Float Takes the Heading's Lead

内側にインセットを持つ見出し

自身の 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));
}
Fix — Float Adopts Lead Plus the Heading's 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 を使います。

Fix — Zero the Trailing Margin Before a Float

補正を 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; }
}
Float Corrections Scoped to the Breakpoint — Resize to Compare

診断

目視では判断できません。折り返された段落の横にある 12px のずれは、光学的な調整のように見えてしまいます。計測しましょう。

const float = document.querySelector('.figure');
const neighbour = float.nextElementSibling;
neighbour.getBoundingClientRect().top - float.getBoundingClientRect().top; // want 0

コンテンツが生み出すすべての構造パターンで実行してください。p → float → h3h2 → float → phr → 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 モードでは決して使いません。

参考リンク

Revision History

作成更新