CSSで、手順の進み具合を示すステップ表示を作る方法
購入手続きや登録の画面で「今どの手順にいるか」を示すステップ表示は、<ol> と CSS だけで作れます。番号は CSS のカウンターで振り、丸どうしをつなぐ線は疑似要素で描きます。今のステップに aria-current="step" を付けておけば、それより前のステップは :has() で選べるので、手順を進めるときに JavaScript がするのは、この属性を付け替えることだけです。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS のページを Playwright で開き、getComputedStyle() の値、要素の位置、Chromium のアクセシビリティツリーを記録しました。強制カラーモードは Playwright のエミュレーションで試しています。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの読み上げ、実際の Windows のハイコントラストモードは試していません。

HTML は ol と aria-current="step" で書く
ステップは順番に意味があるので、番号付きのリスト(<ol>)で書きます。今のステップの <li> にだけ aria-current="step" を付けます。
<nav aria-label="購入の手順">
<ol class="steps">
<li class="step">カートの確認</li>
<li class="step">お届け先</li>
<li class="step" aria-current="step">お支払い</li>
<li class="step">注文の確定</li>
</ol>
</nav>
MDN の aria-current のページには、複数ステップのチェックアウトのように、今のステップを見た目で区別しているときは、そのステップに aria-current="step" が必要だと書かれています。色や形の違いが見えない人にも、どれが今のステップかを伝えるためです。この記事の CSS も、今のステップをセレクターで選ぶのに、この属性を使います。
<nav> で囲むのは、Safari でリストとして扱わせるためでもあります。MDN の list-style のページによると、Safari は list-style: none の <ol> を、<nav> の中にない限りリストとして扱いません。<nav> の外に置くときは <ol role="list"> にします。
丸と番号、つなぐ線を描く CSS
CSS の全体は次のとおりです。
/* 手順の進み具合を示すステップ表示 */
.steps {
display: flex;
margin: 0;
padding: 0;
list-style: none;
counter-reset: step;
}
.step {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-size: 14px;
text-align: center;
color: #5b6870;
word-break: auto-phrase;
counter-increment: step;
}
/* 丸の中の番号 */
.step::before {
content: counter(step);
display: grid;
place-items: center;
box-sizing: border-box;
width: 32px;
height: 32px;
border: 2px solid #9aa5ab;
border-radius: 50%;
background: #fff;
font-weight: bold;
}
/* 前のステップの丸から、このステップの丸までの線 */
.step + .step::after {
content: "";
position: absolute;
top: 15px;
left: calc(-50% + 20px);
right: calc(50% + 20px);
border-top: 2px solid #9aa5ab;
}
/* 今のステップ */
.step[aria-current="step"] {
color: #1f2a30;
font-weight: bold;
}
.step[aria-current="step"]::before {
border-color: #067a98;
color: #067a98;
}
/* 今のステップより前(済んだステップ) */
.step:has(~ .step[aria-current="step"])::before {
content: "✓" / "完了";
border-color: #00c8e8;
background: #00c8e8;
color: #fff;
}
/* 済んだステップと今のステップにつながる線 */
.step:has(~ .step[aria-current="step"])::after,
.step[aria-current="step"]::after {
border-top-color: #00c8e8;
}
番号は CSS カウンターで振ります。.steps の counter-reset: step で step という名前のカウンターを作り、.step ごとに counter-increment: step で1つ進めます。::before の content: counter(step) が、その時点の数を丸の中に書きます。list-style: none でリストの番号を消しているので、番号が2重に出ることはありません。
各ステップの <li> は flex: 1 で同じ幅になります。幅640pxの画面で調べると、4つの <li> はどれも幅152pxでした。
線は、2番目以降のステップの ::after に、前のステップの丸から自分の丸までの長さで描きます。left と right の % は、<li> の幅に対する割合です。left: calc(-50% + 20px) で、線の左端は前の <li> の真ん中から20px右になります。丸の半径は16pxなので、丸の右端から4px離れた位置です。right も同じように、自分の丸の左端の4px手前で線を止めます。
位置を測ると、1番目の丸は左端から76〜108px、2番目の丸は228〜260pxにあり、その間の線は112〜224pxでした。どちらの丸とも4pxずつ離れています。top: 15px は、高さ32pxの丸の真ん中に、太さ2pxの線を合わせるための値です。
済んだステップは :has() と ~ で選ぶ
済んだステップに done のようなクラスを付けて回る必要はありません。.step:has(~ .step[aria-current="step"]) は、「後ろの兄弟に、今のステップがある .step」を選びます。今のステップより前にあるステップ、つまり済んだステップです。
:has() は、かっこの中のセレクターに合う要素がその要素から見つかるときに、その要素を選ぶ擬似クラスです。かっこの中を 後続兄弟結合子の ~ で始めると、自分より後ろの兄弟を探せます。
線の色も同じ考え方で決めます。線は「前の丸から自分の丸まで」なので、済んだステップと今のステップの ::after を水色にすると、今のステップまでの線が水色になります。
3番目に aria-current="step" を付けたとき、1番目と2番目の ::before は content が "✓" / "完了"、背景が水色(rgb(0, 200, 232))になりました。3番目と4番目は counter(step) のままです。線は、2番目と3番目の ::after が水色、4番目が灰色(rgb(154, 165, 171))でした。
手順を進めたり戻したりするには、aria-current を付け替えるだけです。
<button type="button" id="prev">戻る</button>
<button type="button" id="next">次へ</button>
// 「次へ」「戻る」で、aria-current="step" を付ける項目を付け替える
const steps = [...document.querySelectorAll('.step')];
let current = steps.findIndex((li) => li.getAttribute('aria-current') === 'step');
function goTo(index) {
current = Math.min(Math.max(index, 0), steps.length - 1);
steps.forEach((li, i) => {
if (i === current) li.setAttribute('aria-current', 'step');
else li.removeAttribute('aria-current');
});
}
document.querySelector('#next').addEventListener('click', () => goTo(current + 1));
document.querySelector('#prev').addEventListener('click', () => goTo(current - 1));

1番目が今のステップの状態から「次へ」を3回クリックすると、aria-current="step" は2番目、3番目、4番目へと移り、チェックマークの付いた丸は1個、2個、3個と増えました。CSS には何も足していません。
aria-current をどの項目からも外すと、4つとも番号の丸に戻り、線はすべて灰色になりました。すべての手順を終えたあとに「全部済んだ」表示にしたいときは、.steps にクラスを足すなど、別のルールが必要です。
チェックマークには代替テキストを付ける
済んだステップの丸に入れる content: "✓" / "完了" は、/ の後ろが代替テキストです。MDN の content のページのとおり、スラッシュの後ろに書いた文字列が、スクリーンリーダー向けの文字として使われます。
Chromium のアクセシビリティツリーを調べると、済んだステップには「✓」ではなく「完了」の文字が入り、ステップ名と並んでいました。今のステップと、まだのステップには、カウンターの「3」「4」が入っていました。
navigation "購入の手順"
list ""
listitem ""
StaticText "完了"
StaticText "カートの確認"
listitem ""
StaticText "完了"
StaticText "お届け先"
listitem ""
none "" (ignored)
generic ""
StaticText "3"
StaticText "お支払い"
listitem ""
none "" (ignored)
generic ""
StaticText "4"
StaticText "注文の確定"
スマホの幅では、ラベルを文節で折り返す
幅360pxの画面では、<li> の幅は82pxになり、「カートの確認」が1行に収まりません。word-break を指定しないと、「カートの確」で折り返して「認」だけが2行目に回りました。
.step に word-break: auto-phrase を付けると、「カートの」と「確認」の間で折り返しました。MDN では、言語ごとの分析をして、自然なフレーズの途中で改行しないようにする値だと説明されています。

この値は、ページの言語が日本語のときに働きました。同じ CSS のまま <html lang="en"> にしたときも、lang を書かなかったときも、word-break の計算値は auto-phrase のままでしたが、折り返しは「カートの確」と「認」に戻りました。<html lang="ja"> を書いておく必要があります。
つなぐ線は background ではなく border で描く
線は height: 2px と background でも描けますが、強制カラーモードでは消えます。強制カラーモードは、Windows のハイコントラストモードのように、ページの色を限られた色に置き換えて表示するモードです。forced-colors の説明にあるとおり、このモードでは background-color や border-color が、ブラウザの決めた色に置き換わります。
Playwright で強制カラーモードを有効にして開くと、background で描いた線は、3本とも背景色が白(rgb(255, 255, 255))になり、白い背景に溶けて見えなくなりました。border-top で描いた線は黒(rgb(0, 0, 0))になり、丸どうしをつなぐ線が残りました。この記事の CSS で線を border-top にしているのはそのためです。

強制カラーモードでは、丸の水色の塗りもなくなり、どの丸も白地に黒い枠になりました。それでも、済んだステップにはチェックマーク、今のステップには太字のラベルが残るので、進み具合は読み取れます。済んだステップを色だけで区別せず、チェックマークに変えているのはこのためです。
対応しているブラウザ
MDN の互換性データ(@mdn/browser-compat-data 8.1.8)で、この記事で使っている機能を調べました。
| 機能 | Chrome | Firefox | Safari |
|---|---|---|---|
CSS カウンター(counter-reset) |
2 | 1 | 3 |
:has() |
105 | 121 | 15.4 |
content の代替テキスト(/ "完了") |
77 | 128 | 17.4 |
word-break: auto-phrase |
119 | 対応なし | 対応なし(Technology Preview のみ) |
forced-colors |
89 | 89 | 16 |
表の中で、word-break: auto-phrase に対応しているのは Chrome だけでした。Firefox と Safari では文節の切れ目で折り返せないので、狭い画面でも1行に収まるくらいに、ラベルを短くしておくと確実です。





