CSSで、三本線のメニューアイコンを×に変える方法
三本線のメニューアイコンを×に変えるには、上の線と下の線を真ん中の線の位置まで動かしてから、それぞれ逆向きに45度回し、真ん中の線を消します。開いているかどうかは、ボタンの aria-expanded 属性に持たせ、CSS はその値を見て形を切り替えます。こうすると、見た目と、スクリーンリーダーに伝わる状態が1か所でそろいます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS と短い JavaScript のページを Playwright で開き、マウスとキーボードで操作して、getComputedStyle() と getBoundingClientRect() の値を記録しました。動きを減らす設定は、Playwright のエミュレーションで試しています。Firefox と Safari、スマートフォンの実機、実際のスクリーンリーダーでの読み上げは試していません。

ボタンの中に、線を3本の span で並べる
HTML は、button の中に、線にする空の span を3つ入れます。
<button class="menu-btn" type="button" aria-label="メニュー" aria-expanded="false" aria-controls="nav1">
<span class="menu-btn__bar"></span>
<span class="menu-btn__bar"></span>
<span class="menu-btn__bar"></span>
</button>
<nav id="nav1" hidden><a href="/articles/">記事一覧</a></nav>
ボタンの中に文字がないので、aria-label で「メニュー」という名前を付けています。aria-controls には、開け閉めするメニューの id を書きます。
CSS は次のとおりです。
.menu-btn {
--bar-h: 2px; /* 線の太さ */
--gap: 6px; /* 線と線のすき間 */
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: var(--gap);
width: 44px;
height: 44px;
padding: 0;
border: none;
border-radius: 8px;
color: #1f2a30;
background: transparent;
cursor: pointer;
}
.menu-btn__bar {
width: 24px;
height: var(--bar-h);
border-radius: 1px;
background: currentColor;
transition: transform 200ms ease, opacity 200ms ease;
}
/* 上の線:真ん中の線の位置まで下げてから、右下がりに45度回す */
.menu-btn[aria-expanded="true"] .menu-btn__bar:nth-child(1) {
transform: translateY(calc(var(--bar-h) + var(--gap))) rotate(45deg);
}
/* 真ん中の線:消す */
.menu-btn[aria-expanded="true"] .menu-btn__bar:nth-child(2) {
opacity: 0;
}
/* 下の線:真ん中の線の位置まで上げてから、逆向きに45度回す */
.menu-btn[aria-expanded="true"] .menu-btn__bar:nth-child(3) {
transform: translateY(calc(-1 * (var(--bar-h) + var(--gap)))) rotate(-45deg);
}
@media (prefers-reduced-motion: reduce) {
.menu-btn__bar {
transition: none;
}
}
ボタンを縦向きの flex コンテナーにして、3本の線を gap の 6px ずつ空けて並べています。線の太さが 2px なので、線の中心と中心の間は 2px + 6px = 8px です。ボタンの中心から測ると、閉じているときの線の中心は、上の線から順に -8px、0px、8px の位置にありました(マイナスはボタンの中心より上)。
span は本来インラインの要素で、transform は置換要素でないインラインボックスには効きません。この CSS では display: block を書いていません。ボタンを flex コンテナーにしているので、その直接の子の span はフレックスアイテムになります。検証環境でボタンの中の span の display を読むと block になっていて、transform も効きました。ボタンの外に置いた同じ span は inline のままでした。flex を使わずに線を並べる場合は、display: block を自分で書いておきます。
開け閉めは aria-expanded を切り替えるだけにする
JavaScript では、クリックされるたびに aria-expanded の true と false を入れ替え、メニューの hidden 属性も合わせて切り替えます。
for (const btn of document.querySelectorAll('.menu-btn')) {
const nav = document.getElementById(btn.getAttribute('aria-controls'));
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
nav.hidden = open;
});
}
MDN の aria-expanded のページには、ウィジェットを切り替えるボタンは、aria-controls に切り替える相手の id を持ち、aria-expanded に今の状態を持つ必要があるとあります。CSS 用に別のクラスを足さず、この属性をセレクターに使えば、見た目と、スクリーンリーダーなどに伝わる状態がずれることがありません。
1回押した後と2回押した後の値です。
| 押す前 | 1回押した後 | 2回押した後 | |
|---|---|---|---|
aria-expanded |
false |
true |
false |
メニューの hidden |
あり | なし | あり |
| 上の線の中心(ボタンの中心から) | 8px 上 | 中心 | 8px 上 |
真ん中の線の opacity |
1 | 0 | 1 |
| 下の線の中心(ボタンの中心から) | 8px 下 | 中心 | 8px 下 |
| ボタンの大きさ | 44×44px | 44×44px | 44×44px |
開いた後は、上の線も下の線も、中心がボタンの中心にぴったり重なりました。このとき getComputedStyle() で読んだ上の線の transform は matrix(0.707107, 0.707107, -0.707107, 0.707107, 0, 8) です。matrix() の最初の4つの数字が45度の回転、最後の2つが横と縦の移動を表していて、縦に 8px 下がっています。
開いた状態のボタンを Playwright でアクセシビリティツリーとして書き出すと、- button "メニュー" [expanded] になりました。button 要素なので、キーボードでも操作できます。Tab キーでフォーカスを当てて Enter キーを押すと aria-expanded が true に、続けて Space キーを押すと false に戻りました。
translateY と rotate の順番を逆にすると、×がずれる
transform に書く関数の順番は、結果に関わります。上の線の translateY(8px) rotate(45deg) を、逆の rotate(45deg) translateY(8px) にして比べました。

順番を逆にした方では、上の線の中心がボタンの中心から左に 5.66px、上に 2.34px の位置になりました。下の線も左に 5.66px ずれ、2本が交わる点は、ボタンの中心から左に 3.31px ずれています。
MDN の transform のページには、変換の関数は左から右へ組み合わされ、それぞれの関数が次の関数のための新しい座標系を作る、とあります。rotate(45deg) を先に書くと、その後の translateY(8px) は、45度回った向きの「下」へ 8px 動かします。そのため、線は真下ではなく斜め左下へ動きます。逆にした方の transform を読むと matrix(0.707107, 0.707107, -0.707107, 0.707107, -5.65685, 5.65685) で、左に 5.66px、下に 5.66px 動いていました。
記事の CSS のように先に translateY で真ん中まで下げれば、そこから線の中心を軸に回ります。回転の中心になる transform-origin は、既定では要素の中心です。
transform は「動かしてから回す」の順に書きます。動かす距離は、線の太さとすき間を足した値(この例では 8px)にすると、上下の線の中心が真ん中の線の位置にそろいます。
回る動きと、動きを減らす設定
transition は transform と opacity に 200ms かけています。開く途中の値を、transition を一時停止して 50ms ずつ進めながら読み取りました。上の線の transform は matrix() の形で返ってくるので、表では読みやすい値に直しました。回った角度は1番目と2番目の数字から求めて小数第1位に、下へ動いた距離は最後の数字を小数第2位に丸めています。真ん中の線の opacity も小数第2位に丸めました。
| 経過時間 | 上の線が回った角度 | 上の線が下へ動いた距離 | 真ん中の線の opacity |
|---|---|---|---|
| 0ms | 0.0° | 0.00px | 1 |
| 50ms | 18.4° | 3.27px | 0.59 |
| 100ms | 36.1° | 6.42px | 0.20 |
| 150ms | 43.2° | 7.68px | 0.04 |
| 200ms | 45.0° | 8.00px | 0 |
下がる動きと回る動きは同時に進み、上の線は回りながら真ん中へ下りていきます。真ん中の線は、100ms の時点で opacity が 0.2 ほどまで薄くなっていました。
prefers-reduced-motion は、ユーザーが余計な動きを減らすよう求めているかを調べるメディア特性です。Playwright で reduce の設定にして開くと、線の transition は none になりました。押した直後に動いているアニメーションは0個で、上の線の transform はすでに×の位置の値になっていました。動きを減らす設定の人には、三本線と×がすぐに切り替わります。





