CSSのカウンターで、見出しに番号を自動で振る方法
見出しの番号を HTML に直接書くと、節を1つ足しただけで、後ろの番号をすべて書き直すことになります。CSS のカウンターを使えば、h2 には「1.」「2.」、h3 には「1.1」「1.2」と、ブラウザが表示のたびに数えて振ってくれます。HTML の見出しは文字だけのままで済みます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS のページを Playwright で開き、表示された番号、選択した範囲の文字(Selection.toString())、Chromium のアクセシビリティツリーを記録しました。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの読み上げは試していません。

h2 と h3 に番号を振る CSS
HTML には番号を書かず、見出しを並べるだけです。番号を振りたくない見出しには no-number クラスを付けます。
<article class="doc">
<h2>準備する</h2>
<h3>道具をそろえる</h3>
<h3>材料を量る</h3>
<h2>作る</h2>
<h3>生地をこねる</h3>
<h3>焼く</h3>
<h2 class="no-number">おまけ:よくある質問</h2>
<h2>片付ける</h2>
<h3>洗い物をする</h3>
</article>
CSS は次のとおりです。
/* 見出しに「1.」「1.1」の番号を自動で振る */
.doc {
counter-reset: h2;
}
.doc h2 {
counter-increment: h2;
counter-reset: h3;
}
.doc h3 {
counter-increment: h3;
}
.doc h2::before {
content: counter(h2) ". ";
}
.doc h3::before {
content: counter(h2) "." counter(h3) " ";
}
/* 番号を振らない見出し */
.doc .no-number {
counter-increment: none;
}
.doc .no-number::before {
content: none;
}
使っているのは3つのプロパティと1つの関数です。counter-reset でカウンターを作って 0 から数え始め、counter-increment で1つ進めます。その時点の数を、::before の content の中の counter() で書き出します。h2 や h3 は、ここで付けたカウンターの名前で、要素の名前と同じにする必要はありません。
ポイントは、h2 に counter-reset: h3 を書いていることです。h2 が出てくるたびに h3 のカウンターが 0 に戻るので、次の h3 は「2.1」から数え直します。h3 の番号に h2 の数も入れたいので、h3::before では counter(h2) と counter(h3) を並べています。
no-number の見出しは、counter-increment: none で数えるのをやめ、content: none で番号を消しています。MDN の counter-increment のページにあるとおり、none はその要素でカウンターを進めない値です。検証環境(Chromium 153)で表示された見出しの番号は次のとおりで、「おまけ」を飛ばして「3. 片付ける」に続きました。
h2: 1. 準備する
h3: 1.1 道具をそろえる
h3: 1.2 材料を量る
h2: 2. 作る
h3: 2.1 生地をこねる
h3: 2.2 焼く
h2: おまけ:よくある質問
h2: 3. 片付ける
h3: 3.1 洗い物をする
番号は表示のたびに数え直されるので、あとから見出しを足しても、HTML の番号を書き換える必要はありません。次の GIF は、JavaScript で1つ目の節のすぐ後ろに <h2>新しい節</h2> を足していく様子です。CSS は上と同じものを使っています。

番号がずれる、よくある書き方
counter-reset を書く場所を間違えると、番号がずれます。検証環境で、上の CSS から書く場所だけを変えて試しました。
h3 のリセットを h2 ではなく、外側の .doc にまとめて counter-reset: h2 h3 と書くと、h3 が節をまたいで数え続けます。2つ目の節は「2.3」「2.4」から始まり、3つ目の節の h3 は「3.5」になりました。
h2: 2. 作る
h3: 2.3 生地をこねる
h3: 2.4 焼く
h2: おまけ:よくある質問
h2: 3. 片付ける
h3: 3.5 洗い物をする
逆に、.doc に書いていた counter-reset: h2 を、h2 自身に書いて counter-reset: h2 h3 にすると、h2 はどれも「1.」になりました。h2 が出てくるたびに、その h2 の counter-reset が値 0 の新しいカウンターを作り、同じ要素の counter-increment で 1 になるためです。MDN のカウンターの使い方のページにあるとおり、要素がカウンターを作ると、前の兄弟から受け取った同じ名前のカウンターは取り消されます。h2 のカウンターは、見出しを囲む要素で1回だけ作ります。
もう1つ、最初の h2 より前に h3 を置くと、その h3 は「0.1」になりました。h2 のカウンターがまだ 0 のまま、h3 が数えられるためです。その h3 に no-number を付けると、番号は出なくなり、後ろの見出しは「1.」「1.1」から始まりました。
番号を、読み上げ用の名前に入れる
::before で足した番号は、HTML の文字ではありません。検証環境で、見出しを囲む .doc の中身を選択したときの文字(Selection.toString())と innerText を調べると、どちらにも番号は入っておらず、「準備する」「道具をそろえる」のように見出しの文字だけでした。
Chromium のアクセシビリティツリーで、見出しのアクセシブルな名前も調べました。この記事の CSS のままだと、名前は「. 準備する」「. 道具をそろえる」でした。番号の数字が抜け、区切りの「.」だけが残っています。ツリーの子のノードには「1」の文字があり、番号そのものは残っていました。
番号も名前に入れたいときは、content の代替テキストを使います。MDN の content のページによると、/ の後ろに書いた文字列やカウンターが代替テキストになり、アクセシビリティツリーに公開されます。
.doc h2::before {
content: counter(h2) ". ";
content: counter(h2) ". " / counter(h2) ".";
}
.doc h3::before {
content: counter(h2) "." counter(h3) " ";
content: counter(h2) "." counter(h3) " " / counter(h2) "." counter(h3);
}
こう書くと、見出しの名前は「1. 準備する」「1.1 道具をそろえる」になりました。反対に、番号を名前に入れたくないなら / "" と書きます。このときの名前は「準備する」だけになりました。どちらの書き方でも、画面のスクリーンショットは代替テキストなしのときと同じでした。
代替テキストのない content を先に書いているのは、代替テキストに対応していないブラウザのためです。MDN の CSS のエラー処理のページにあるとおり、ブラウザは解釈できない宣言を無視するので、古い書き方と新しい書き方を同じルールに並べておけます。対応しているブラウザでは後に書いた宣言が使われ、対応していないブラウザでは2つ目が無視されて1つ目が残ります。
「第1章」や「第一章」のように書く
content には文字列とカウンターを並べられるので、"第" counter(h2) "章 " と書けば「第1章」になります。counter() の2つ目の引数にカウンターの書式を渡すと、漢数字にもできます。
.decimal h2::before { content: "第" counter(h2) "章 "; }
.cjk h2::before { content: "第" counter(h2, cjk-decimal) "章 "; }
.informal h2::before { content: "第" counter(h2, japanese-informal) "章 "; }
h2 を12個並べて試すと、漢数字の2つの書式は、10番目から違いが出ました。
| 書式 | 1番目 | 10番目 | 12番目 |
|---|---|---|---|
指定なし(decimal) |
第1章 | 第10章 | 第12章 |
cjk-decimal |
第一章 | 第一〇章 | 第一二章 |
japanese-informal |
第一章 | 第十章 | 第十二章 |
cjk-decimal は数字を1けたずつ漢数字にするので、10は「一〇」になります。「第十章」と書きたいときは japanese-informal を使います。
目次の番号を見出しとそろえる
目次を入れ子の <ol> で書くなら、counters() を使うと、「1.1」のような番号を1つのカウンターで書けます。counters() は、外側の <ol> のカウンターから順に、すべての値を区切りの文字でつないで返します。
/* 目次の番号を、入れ子の ol で「1.」「1.1」にする */
.toc ol {
margin: 0;
padding-left: 1.5em;
list-style: none;
counter-reset: item;
}
.toc li {
counter-increment: item;
}
.toc > ol > li::before {
content: counter(item) ". ";
}
.toc ol ol li::before {
content: counters(item, ".") " ";
}
<ol> ごとに counter-reset: item を書くと、内側の <ol> では、外側の item とは別の item のカウンターが作られます。MDN のカウンターの使い方のページにある、入れ子になったカウンターの例と同じ考え方です。list-style: none で <ol> 自身の番号は消しています。

目次に表示された番号は「1. 準備する」「1.1 道具をそろえる」……「3.1 洗い物をする」で、見出しの番号と同じになりました。目次と見出しは、それぞれ別のカウンターで数えています。
長い見出しを、番号の右で折り返す
幅360pxの画面で、「2.1 生地をこねるときは、水を少しずつ加えて固さを見る」という長い h3 を表示すると、2行目は番号の下(見出しの左端)から始まりました。番号の右に文字の頭をそろえたいときは、見出しを display: flex にして、番号を別の箱にします。
/* 見出しが2行になっても、2行目を番号の右にそろえる */
.doc h2,
.doc h3 {
display: flex;
gap: 0.5em;
}
.doc h2::before,
.doc h3::before {
flex: none;
}
見出しを flex コンテナーにすると、::before の番号と見出しの文字は、横に並ぶ別々の箱になります。番号の箱には flex: none を付けて、伸び縮みしないようにします。文字の箱は残りの幅に収まり、その中で折り返します。番号と文字の間は gap で空けます。

文字の各行の左端を測ると、::before に入れただけのときは1行目が60px、2行目が32px(見出しの左端と同じ)でした。flex にしたときは、どちらの行も64pxで、2行目が1行目の頭にそろいました。
対応しているブラウザ
MDN の互換性データ(@mdn/browser-compat-data 8.1.8)で、この記事で使っている機能を調べました。
| 機能 | Chrome | Firefox | Safari |
|---|---|---|---|
counter-reset、counter-increment |
2 | 1 | 3 |
counter() |
1 | 1 | 3 |
counters() |
1 | 1.5 | 3 |
content の代替テキスト(/ counter(h2) ".") |
77 | 128 | 17.4 |
cjk-decimal(list-style-type の値として) |
91 | 28 | 15 |
japanese-informal(list-style-type の値として) |
91 | 28 | 15 |
カウンターそのものは、どのブラウザでも古くから使えます。新しめなのは content の代替テキストで、Firefox は 128、Safari は 17.4 から対応しています。





