Prose Heading Spacing
問題
Markdown から HTML へのコンバーターは、段落、リスト、テーブル、見出しといったブロック要素をフラットに並べた構造を出力します。よくあるスペーシング戦略は、すべてのブロック要素に同じ bottom margin を設定して均一なリズムを作り、見出しにはさらに大きな top margin を加えてセクション境界を読者に認識させるというものです。
この方法は、見出しが連続するまではうまく機能します。
<h2>Getting Started</h2>
<h3>Prerequisites</h3>
<h4>Node.js Version</h4>
<p>Install Node.js 20 or later.</p>
各見出しがそれぞれ大きな top margin を持つため、連続するとスペースが蓄積され、ページ上のどの余白よりもはるかに大きな視覚的な隙間が生まれます。flex や grid コンテナ内ではマージンの相殺が起きないため、問題はさらに悪化します。
MDX ではコンポーネントラッパーやアドモニションが見出しとコンテンツの想定されるフローを中断する可能性があり、固定的なマージンルールが壊れやすくなります。
解決方法
スペーシングを個々の要素から切り離します。各要素が自分自身のマージンを持つのではなく、兄弟要素間の関係としてスペーシングを定義します。これが「フロー」パターンです。親コンテナのルールで隣接する子要素間のギャップを制御し、見出しがそのギャップをオーバーライドしてセクションの区切りを作ります。
連続する見出しの問題は、1 つのオーバーライドで解決できます。見出しの直後に別の見出しが続く場合、スペーシングを縮めるだけです。
本番で使える 3 つの戦略があり、それぞれトレードオフが異なります。
コード例
戦略 1: フローユーティリティと見出しオーバーライド
フローユーティリティは、コンテナにスコープされたロボトミーアウルセレクタ(* + *)を使います。各見出しがより大きなフロースペースを設定し、1 つのルールで連続する見出しを詰めます。
.prose > * + * {
margin-block-start: var(--flow-space, 1em);
}
/* Headings create section separation */
.prose :where(h2) { --flow-space: 2.5em; }
.prose :where(h3) { --flow-space: 2em; }
.prose :where(h4) { --flow-space: 1.5em; }
/* Consecutive headings: tighten spacing */
.prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) {
--flow-space: 0.5em;
}
.prose > :first-child {
margin-block-start: 0;
}
これが最も堅牢なアプローチです。スペーシングは一方向のマージン(margin-block-start)のみで制御されるため、block flow、flex、grid のいずれでも同じ動作になります。マージンの相殺に依存しません。
戦略 2: Tailwind Typography スタイル
Tailwind Typography は異なるアプローチを取ります。見出しが top と bottom の両方のマージンを持ち、ワイルドカードルールで見出しの直後の要素の top margin をゼロにします。
.prose h2 { margin-block: 2em 1em; }
.prose h3 { margin-block: 1.6em 0.6em; }
.prose h4 { margin-block: 1.5em 0.5em; }
.prose p,
.prose ul,
.prose ol,
.prose table,
.prose pre {
margin-block-end: 1em;
}
/* Zero out next sibling's top margin after any heading */
.prose :is(h2, h3, h4) + * {
margin-block-start: 0;
}
h2 の後に h3 が来ると、h3 の top margin が 0 になります。残るのは h2 の bottom margin(1em)のみです。これにより、明示的なペアルールなしで連続する見出しを処理できます。
トレードオフとして、見出しの後のあらゆる要素の top margin がゼロになります。つまり、見出し直後の最初の段落は常に見出しに密着します。これは通常望ましい動作ですが、そのギャップを個別に微調整する余地がなくなります。
戦略 3: :has() — 先行する見出しをターゲットにする
:has() セレクタを使うと、親側からの制御が可能になります。別の見出しが後に続く場合に、先行する見出しの bottom margin を縮めます。
:is(h2, h3, h4):has(+ :is(h2, h3, h4, h5, h6)) {
margin-block-end: 0.25em;
}
これは連続するペアの最初の見出しをターゲットにし、その bottom margin を縮小します。2 番目の見出しは通常の top margin を維持します。合計すると、ギャップは妥当なサイズに収まります。
このアプローチは正確です。見出しペア間のスペーシングのみを調整し、見出しからコンテンツへのスペーシングには影響しません。ブラウザサポートはモダンブラウザで広く対応しています(Chrome 105+、Firefox 121+、Safari 15.4+)。
トレードオフとして、この戦略は両方の見出しがそれぞれマージン(top と bottom)を持つことに依存しています。flex や grid コンテナではマージンが相殺されないため、連続する見出し間のギャップは block flow よりも大きくなります。prose コンテナが flex や grid のカラムになる可能性がある場合は、フローユーティリティ(戦略 1)または Tailwind スタイル(戦略 2)を使いましょう。
flex/grid でのマージン相殺の罠
通常の block flow では、隣接する垂直方向のマージンは相殺されます。大きい方のマージンが採用されます。多くの prose スペーシング戦略は、この動作に暗黙的に依存しています。しかし flex と grid コンテナではマージンは相殺されません。両方のマージンがそのまま適用され、ギャップが 2 倍になります。
markdown コンテンツのコンテナが flex や grid レイアウト内(サイドバー、目次パネル、マルチカラムレイアウトなど)にレンダリングされるケースが増えているため、この点は重要です。
/* This relies on margin collapse — breaks in flex/grid */
.prose-block h2 { margin-block: 2em 1em; }
.prose-block h3 { margin-block: 1.6em 0.6em; }
/* h2 bottom (1em) + h3 top (1.6em) = 1.6em in block flow (collapsed) */
/* h2 bottom (1em) + h3 top (1.6em) = 2.6em in flex/grid (stacked) */
/* This works everywhere — only one margin per gap */
.prose-flow > * + * { margin-block-start: var(--flow-space, 1em); }
本番向けの組み合わせアプローチ
本番の prose コンテナでは、フローユーティリティと :has() を組み合わせることで最大限の制御が可能です。
.prose > * + * {
margin-block-start: var(--flow-space, 1em);
}
/* Section separation before headings */
.prose :where(h2) { --flow-space: 2.5em; }
.prose :where(h3) { --flow-space: 2em; }
.prose :where(h4) { --flow-space: 1.5em; }
/* Tighter gap between heading and its first content */
.prose :where(h2, h3, h4) + :where(p, ul, ol, table, pre) {
--flow-space: 0.5em;
}
/* Consecutive headings: tight grouping */
.prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) {
--flow-space: 0.5em;
}
/* Trim edges */
.prose > :first-child { margin-block-start: 0; }
.prose > :last-child { margin-block-end: 0; }
特定の見出しペアの微調整
組み合わせアプローチでは、すべての連続する見出しペアを同じスペーシングで扱います。デザインが特定のペア間で異なるスペーシングを求める場合 — たとえば、h2 と h3 の間(メインセクションからサブセクションへの遷移)は h3 と h4 の間(マイナーなネスト)より広くしたい場合 — 明示的なペアオーバーライドを追加します。
/* Base: all consecutive headings get the same tight spacing */
.prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) {
--flow-space: 0.5em;
}
/* Fine-tune: h2 → h3 gets slightly more room */
.prose :where(h2) + :where(h3) {
--flow-space: 0.75em;
}
/* Fine-tune: h3 → h4 stays tighter */
.prose :where(h3) + :where(h4) {
--flow-space: 0.4em;
}
:where() ラッパーによって詳細度がフラットに保たれるため、宣言順で適用されるルールが決まります。ペア固有のオーバーライドは、連続する見出しの一括ルールの後に配置してください。
これはオプションの調整レイヤーです。ほとんどの prose レイアウトは、連続する見出しに均一なスペーシングを適用すれば十分です。ペア固有のオーバーライドは、見出し遷移間の視覚的な階層の違いをデザインが明示的に求める場合にのみ追加しましょう。
クイックリファレンス
| シナリオ | 戦略 | キーセレクタ |
|---|---|---|
| 均一な兄弟スペーシング | フローユーティリティ | .prose > * + * { margin-block-start: ... } |
| 見出しのセクション区切り | --flow-space のオーバーライド | .prose :where(h2) { --flow-space: 2.5em } |
| 連続する見出しの詰め | 隣接見出しセレクタ | :where(h2,h3,h4) + :where(h2,h3,h4,h5,h6) |
| 見出し後の次兄弟のゼロ化 | Tailwind スタイル | :is(h2,h3,h4) + * { margin-block-start: 0 } |
| 先行する見出しのマージン縮小 | :has() セレクタ | :is(h2,h3):has(+ :is(h3,h4,h5)) { margin-block-end: ... } |
| ペア固有の見出し調整 | 明示的なペアオーバーライド | :where(h2) + :where(h3) { --flow-space: 0.75em } |
| flex/grid セーフなスペーシング | 単方向マージン | margin-block-start のみを使用し、両方向は避ける |
AI がよくやるミス
- flex/grid コンテキストを考慮せずに見出しに
margin-topとmargin-bottomの両方を設定する。 block flow ではマージンが相殺されますが、flex/grid ではスタックされます。同じ CSS でも親コンテナによって異なるスペーシングになります。 - 連続する見出しのオーバーライドなしに、すべての見出しに大きな
margin-topを追加する。 h2 + h3 + h4 のスタックで巨大なギャップが生まれます。 - マージンの相殺をスペーシング戦略として依存する。 マージンの相殺はレイアウトコンテキストが変わると壊れる暗黙的な動作です。明示的な単方向マージンの方が予測しやすくなります。
- 同じ要素タイプに異なるマージン値を使う。 ある段落は
margin-bottom: 16px、別の段落は20pxになっているケースです。フローユーティリティによる均一なスペーシングでこれを防げます。 - 最初の子要素の
margin-block-startをゼロにしない。 prose コンテナの最初の要素はコンテナの端にぴったり配置されるべきです。:first-child { margin-block-start: 0 }がないと、フローユーティリティが不要な上部スペースを追加します。 - 見出しからコンテンツへのスペーシングを詰めるのを忘れる。 見出しの下のスペースは上のスペースより小さくする必要があります。見出しは後続のコンテンツに視覚的に「属する」べきであり、セクション間で等間隔に浮くべきではありません。
使い分け
フローユーティリティと見出しオーバーライド
markdown/MDX の prose コンテナのデフォルトの選択肢です。どのレイアウトコンテキストでも動作します。任意の数の見出しレベルにスケールします。ドキュメントサイト、ブログ記事テンプレート、コンテンツ中心のレイアウトを構築する場合に使いましょう。
Tailwind Typography スタイル
Tailwind の @tailwindcss/typography プラグインを採用する場合や、同様の opinionated な prose システムを構築する場合に使います。heading + * ワイルドカードはシンプルですが、見出しからコンテンツへのギャップの細かな制御ができなくなります。
:has() セレクタアプローチ
block flow コンテナ内の既存の見出しマージンに対する、ターゲットを絞った修正として使います。マージン戦略を再構築せずに既存のスタイルシートにスペーシングを後付けする場合に特に有効です。フローユーティリティパターンの採用は不要です。マージンが相殺されない flex や grid コンテナでは避けてください。期待より大きなギャップが残ります。
明示的なペアルール
コンテンツ構造が厳密に管理されている場合(見出しのネストを制限する CMS など)かつ、見出しの組み合わせ数が少ない場合にのみ使います。任意の markdown コンテンツにはスケールしません。
Tailwind CSS
複雑な隣接兄弟セレクタ(:where(h2,h3,h4,h5,h6) + :where(h2,h3,h4,h5,h6))は Tailwind のユーティリティクラスだけでは表現できません。3 つのアプローチがあります。
@tailwindcss/typography を使う
@tailwindcss/typography プラグインは連続する見出しをすでに処理しています。prose クラスを適用すれば、見出しのスペーシングは自動的に管理されます。戦略 2 で説明した h2 + *、h3 + *、h4 + * のリセットパターンも含まれています。
<article class="prose">
<h2>Getting Started</h2>
<h3>Prerequisites</h3>
<p>Typography plugin handles the spacing.</p>
</article>
Tailwind v4 でカスタム CSS を書く
Tailwind v4 の CSS ファーストな設定では、スタイルシートにセレクタを直接書けます。見出しスペーシングのルールを @layer に追加します。
@layer components {
.prose > * + * {
margin-block-start: var(--flow-space, 1em);
}
.prose :where(h2) { --flow-space: 2.5em; }
.prose :where(h3) { --flow-space: 2em; }
.prose :where(h4) { --flow-space: 1.5em; }
.prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) {
--flow-space: 0.5em;
}
.prose > :first-child { margin-block-start: 0; }
}
@tailwindcss/typography を使わない場合の推奨アプローチです。Tailwind のレイヤーシステムでフルセレクタの制御が可能です。
シンプルなケース向けの Tailwind ユーティリティ
見出しオーバーライドが 1 レベルだけ必要なシンプルな prose レイアウトでは、Tailwind の arbitrary variant 構文が使えます。
<article class="[&>*+*]:mt-4 [&>h2]:mt-10 [&>h3]:mt-8">
<h2>Section</h2>
<h3>Subsection</h3>
<p>Content</p>
</article>
これは連続する見出しの問題を処理しません。対応するには arbitrary variant を以下のようにします。
<article class="[&>:is(h2,h3,h4)+:is(h2,h3,h4,h5,h6)]:mt-2">
...
</article>
動作はしますが、読みにくく保守が困難です。長い arbitrary variant よりも CSS @layer アプローチか @tailwindcss/typography を使いましょう。