ノート風ハンバーガーメニューとは、repeating-linear-gradient で罫線を、疑似要素で綴じ目を描いて、紙のノートの見た目を再現した開閉メニューです。見た目はCSSだけで作れますが、開閉ボタンを <div> で作ると、キーボードだけで操作する人はそのメニューを一度も開けません。
この記事は、罫線と綴じ目をCSSで描く手順に加えて、実際に自分で作ったデモを実ブラウザで検査したら何が動いていなかったのかを実測値つきで示し、<button>・aria-expanded・inert で直すところまでを1本で通します。ライブラリは使いません。
最後に、あなた自身の実装が本当に直っているかを、見た目ではなくブラウザのコンソールで判定する手順を置きます。合格ラインと、外れたときにどこを見ればよいかまで書いてあります。
最初に作ったノート風メニュー(修正前)
作るものは、右端からスライドして出てくる紙のノート風のメニューです。要素は3つに分解できます。
- 横罫線:メニューの背景に、等間隔の横線を引く
- 綴じ目のミシン目:右端に、破線状の縦線を1本入れる
- 開閉:ハンバーガーボタンで、右からスライドさせる
下の埋め込みは、最初に作った修正前の実装です。見た目の完成イメージを示すために、あえてそのまま残してあります。コピーして使うのは、この記事の「修正版のコピペで動くコード」の章にあるコードにしてください。修正前の実装には、次の章で示すとおりキーボード操作の欠陥が残っています。
See the Pen Untitled by masakazuimai (@masakazuimai) on CodePen.
マウスで触るぶんには、狙いどおりに動きます。罫線も綴じ目も出ていますし、スマホ幅でも開きます。問題は、マウスを使わない人がこのメニューをどう操作するのかを一度も確かめていなかったことでした。
検査したら、キーボードでは一度も開けなかった
上のデモをローカルに置き、実ブラウザ(Chromium 151)を自動操作して計測しました。判定はすべてDOMから読み取った値で、開発者ツールのパネルの見え方は根拠にしていません。結果は次のとおりです。
| 検査したこと | 修正前の実測値 | 読者に起きること |
|---|---|---|
| Tabキーでボタンに到達するか | 8回押しても到達しない(tabIndex は -1) | キーボードだけではメニューを開けない |
| 強制的にフォーカスしてEnter・Space | クラスが付かず開かない | 同上。回避手段がない |
| 閉じている状態のメニュー内リンク | 4本すべてフォーカスを受け取る(x座標1990=画面外) | 何も見えないままTabを4回空振りする |
開いた後の aria-expanded | null | 開いているか閉じているかが支援技術に伝わらない |
動きを減らす設定での transition-duration | メニュー 0.5s/アイコン 0.3s | 設定を有効にしても動きが減らない |
| ビューポート幅601pxでのメニュー幅 | 600px(画面の99.8%) | 小型タブレットで実質フルスクリーンになる |
上の1行目と2行目は、WCAG 2.2の達成基準 2.1.1 キーボード(Understanding・2026年8月2日取得)にそのまま抵触します。3行目は 2.4.3 フォーカス順序(同)の問題です。見た目が完成していても、操作経路が1本しかない状態でした。
ハンバーガーメニューそのものの標準的な作り方はハンバーガーメニューをCSSとJavaScriptで実装する基本の型にまとめてあります。この記事はその総論には踏み込まず、ノート風という特定の見た目を作り切り、そこにキーボード操作を通すところまでを扱います。
修正版のコピペで動くコード
先に完成形を置きます。まず動かしたい場合は、この3ブロックをそのまま持っていけば動きます。理屈は次の章から順に分解します。
<button type="button" class="hamburger" id="hamburger"
aria-expanded="false" aria-controls="menu" aria-label="メニューを開く">
<span></span><span></span><span></span>
</button>
<nav class="menu" id="menu" inert>
<ul>
<li><a href="#">ホーム</a></li>
<li><a href="#">プロフィール</a></li>
<li><a href="#">サービス</a></li>
<li><a href="#">お問い合わせ</a></li>
</ul>
</nav>
:root { --note-base: #f7f1aa; --note-hi: #fff9b3; --note-rule: #c1c1c1; --ink: #333; }
.hamburger {
position: fixed; top: 20px; right: 20px; z-index: 1001;
width: 44px; height: 44px;
display: flex; flex-direction: column; justify-content: center; gap: 6px;
padding: 0; border: none; background: transparent; cursor: pointer;
}
.hamburger span {
display: block; height: 3px; width: 30px; margin-inline: auto;
background-color: var(--ink); border-radius: 3px;
transition: transform .3s, opacity .3s;
}
.hamburger:focus-visible { outline: 3px solid #0b57d0; outline-offset: 3px; }
.hamburger[aria-expanded="true"] span:nth-child(1) { transform: translateY(9px) rotate(45deg); }
.hamburger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.hamburger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-9px) rotate(-45deg); }
.menu {
position: fixed; inset-block: 0; inset-inline-end: 0; z-index: 1000;
width: min(600px, 80vw);
padding: 60px 20px; box-sizing: border-box;
background: repeating-linear-gradient(var(--note-base), var(--note-hi) 20px, var(--note-rule) 21px);
border-inline-start: 10px solid #ccc;
box-shadow: -5px 0 15px rgb(0 0 0 / .1);
transform: translateX(100%);
visibility: hidden;
transition: transform .5s ease, visibility .5s;
}
.menu::before {
content: "";
position: absolute; inset-block: 0; inset-inline-end: 10px;
width: 2px;
background: repeating-linear-gradient(#888 0, #888 5px, transparent 5px, transparent 15px);
}
.menu.is-open { transform: translateX(0); visibility: visible; }
.menu ul { list-style: none; padding: 0; margin: 0; }
.menu li { margin: 20px 0; }
.menu a {
color: var(--ink); font-size: 18px; text-decoration: none;
font-family: ui-monospace, Menlo, monospace;
}
.menu a:focus-visible { outline: 3px solid #0b57d0; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.menu, .hamburger span { transition: none; }
}
const hamburger = document.getElementById("hamburger");
const menu = document.getElementById("menu");
const setOpen = (open) => {
hamburger.setAttribute("aria-expanded", String(open));
hamburger.setAttribute("aria-label", open ? "メニューを閉じる" : "メニューを開く");
menu.classList.toggle("is-open", open);
menu.inert = !open;
};
hamburger.addEventListener("click", () => {
setOpen(hamburger.getAttribute("aria-expanded") !== "true");
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hamburger.getAttribute("aria-expanded") === "true") {
setOpen(false);
hamburger.focus();
}
});
このコードを実ブラウザで計測した結果です。修正前の表と同じ項目を並べてあります。
| 検査したこと | 修正版の実測値 |
|---|---|
| Tabキーでボタンに到達するか | 1回目で到達(BUTTON / tabIndex は 0) |
| Enter・Spaceで開くか | どちらでも aria-expanded が "true" になる |
| 閉じている状態のメニュー内リンク | 4本ともフォーカスを受け取らない |
| 開いた状態のメニュー内リンク | 4本ともTabで順に到達する |
| Escapeで閉じたときのフォーカス | ハンバーガーボタンに戻る |
| 綴じ目の疑似要素 | content は ""/width は 2px |
動きを減らす設定での transition-duration | メニュー・アイコンとも 0s |
| ビューポート幅601px・375pxでのメニュー幅 | どちらも画面幅の80%(481px/300px) |
自分で書かずに候補から選びたい場合は、ハンバーガーメニューCSSをコピペで使える無料ツールにアイコンの変形と開閉パターンの組み合わせをまとめてあります。
ノートの見た目をCSSで作る
紙のノートらしさは、罫線と綴じ目の2つでほぼ決まります。どちらも画像を使わずCSSだけで描けます。
罫線は repeating-linear-gradient 1行で引ける
background: repeating-linear-gradient(#f7f1aa, #fff9b3 20px, #c1c1c1 21px);
読み方は「色停止位置の最後の値が1周期の長さになる」です。ここでは最後が 21px なので21pxごとに同じ模様が繰り返され、0pxから20pxまでが紙の面、20pxから21pxまでの1pxぶんが線になります。MDNの repeating-linear-gradient(2026年8月2日取得)にあるとおり、繰り返しの単位は最初と最後の色停止位置の差で決まります。
行の高さを変えたいときは、2つ目と3つ目の数値を同じだけずらします。30px と 31px にすれば行間が広がり、差を 2px にすれば線が太くなります。色と間隔を手で詰めたい場合はCSSグラデーションジェネレーターで当たりを付けると早いです。
綴じ目のミシン目は疑似要素で描く
綴じ目は、メニュー本体の背景とは向きが違います。罫線が横方向なのに対して、綴じ目は縦に走る破線です。要素をもう1つ足すのではなく、::before で描きます。
.menu::before {
content: "";
position: absolute; inset-block: 0; inset-inline-end: 10px;
width: 2px;
background: repeating-linear-gradient(#888 0, #888 5px, transparent 5px, transparent 15px);
}
幅2pxの縦棒を1本置き、その中を5px塗って10px空けるグラデーションで埋めています。inset-block: 0 は上下を親いっぱいに広げる指定で、top: 0; bottom: 0 と同じ意味です。inset-inline-end: 10px は横書きの日本語では右から10pxを指します。MDNの ::before(2026年8月2日取得)のとおり、疑似要素は親の中の最初の子として描かれるので、メニュー本体に position: fixed が付いていれば基準はそのまま本体になります。
content を書き忘れると疑似要素はそもそも存在しない
この記事の元になった実装で実際に起きていた不具合です。content を1行落とすと、位置も幅も背景も正しいのに、綴じ目は1ピクセルも表示されません。content の初期値は normal で、MDNの content(2026年8月2日取得)には「::before と ::after では normal は none に計算される」「none を疑似要素に適用すると、その疑似要素は生成されない」と書かれています。
実際に content を抜いたCSSだけを貼ったページを計測すると、次の値が返ります。表示されないのではなく、要素として存在していません。
const cs = getComputedStyle(document.querySelector('.menu'), '::before');
console.log(cs.content, cs.width);
content を書いていないときは none と auto が返ります。content: ""; を足したあとは、1つ目が空文字、2つ目が 2px に変わります。綴じ目が出ないときは、色や座標を疑う前にこの1行を実行するのが最短です。
開閉をキーボードでも使えるようにする
ここからが、最初のデモで全滅していた部分です。開閉メニューの標準的な作法は、W3Cが公開しているWAI-ARIA Authoring Practices の Disclosure パターン(2026年8月2日取得)にまとまっています。要求されているのは3つだけで、フォーカスの閉じ込め(フォーカストラップ)は含まれません。キーボード操作をWCAG全体の中で位置づけたい場合はWebアクセシビリティの基本もあわせて確認してください。
div ではなく button にする
Disclosure パターンは「内容を表示・非表示する要素は button のロールを持つ」と定めています。キーボード操作についても「フォーカスがあるときEnterとSpaceで表示が切り替わる」と明記されています。この2つは <button type="button"> を使うだけで満たせます。
| 項目 | div で作った場合 | button で作った場合 |
|---|---|---|
| Tabキーでの到達 | しない | する |
| Enter・Space | 反応しない | 反応する |
| 支援技術から見た役割 | 役割なし | button |
| フォーカスリング | 出ない | 出る |
tabindex="0" を足せばTabでは到達できますが、EnterとSpaceで押せる挙動までは付いてきません。<div> のまま直そうとすると、キーの検知とロールの付与を自分で書く必要が出てきます。<button> にすれば全部が標準で付いてくるので、こちらのほうが短く済みます。
中身が <span> 3本だけだと読み上げるテキストがないので、aria-label で名前を与えます。ボタン自体は44px四方にしておくと、指でも押しやすくなります。
開閉状態は aria-expanded で伝える
Disclosure パターンの記述はこうです。「内容が表示されているとき、button のロールを持つ要素は aria-expanded を true にする。内容が隠れているときは false にする」。クラスの付け外しだけで管理していると、画面の見た目は変わっても、支援技術から見た状態は開く前と後で同じままになります。
hamburger.setAttribute("aria-expanded", String(open));
hamburger.setAttribute("aria-label", open ? "メニューを閉じる" : "メニューを開く");
属性を状態の保管場所にしておくと、CSS側も .hamburger[aria-expanded="true"] で拾えるので、アイコンを×に変形させるルールを状態と同期させられます。開閉用のクラスを別に持って二重管理する必要がありません。MDNの aria-expanded(2026年8月2日取得)も、値は文字列の true / false だと説明しています。
なお aria-controls は必須ではありません。Disclosure パターンでは「任意で、button のロールを持つ要素に、表示・非表示される内容を含む要素を指す aria-controls の値を指定する」と書かれています。対象に id があるなら付けておくと対応関係が伝わりますが、無いことを欠陥として扱う必要はありません。
閉じている間はフォーカスを入れない
最初のデモで一番わかりにくかったのがこれです。right: -100% でメニューを画面の外に飛ばしていましたが、要素そのものは表示状態のままでした。実測したTab順は、ハンバーガーに触れることなく、画面外にあるリンク4本を順に通過していました。
解決策は2つを併用します。inert 属性を付けるとその要素と子孫はフォーカスを受け取らず、支援技術からも外れます。あわせて visibility: hidden を指定しておくと、inert に対応していない古い環境でもフォーカスの対象から外れます。
menu.classList.toggle("is-open", open);
menu.inert = !open;
visibility は transition の対象にできるので、閉じるアニメーションが終わってから隠れるよう transition: transform .5s ease, visibility .5s; のように並べて書きます。display: none だとアニメーションが途中で切れます。inert の詳細はMDNの inert(2026年8月2日取得)にあります。
Escapeで閉じてフォーカスを戻す
これは Disclosure パターンの必須要件ではありません。パターンが求めているのはEnterとSpaceによる切り替えだけで、Escapeでの閉じ方は規定されていません。それでも実用上は入れる価値があります。開いたあとにハンバーガーへ戻る道がないと、メニューを閉じるためだけにTabで一周する必要が出てくるためです。
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hamburger.getAttribute("aria-expanded") === "true") {
setOpen(false);
hamburger.focus();
}
});
閉じたあとに hamburger.focus() を呼ぶのが要点です。これを書かないと、inert が付いた瞬間にフォーカスがどこにも属さない状態になり、次のTabがページの先頭から始まります。実測では、Escapeのあと document.activeElement がハンバーガーボタンに戻ることを確認しています。
動きを減らす設定に対応する
OSには、視差効果やアニメーションを減らす設定があります。ブラウザはその設定を prefers-reduced-motion として公開しているので、CSS側で受け取って動きを止められます。最初のデモはこの分岐を持っていなかったため、設定を有効にしてもメニューは 0.5s、アイコンは 0.3s のまま動いていました。
@media (prefers-reduced-motion: reduce) {
.menu, .hamburger span { transition: none; }
}
書き漏らしやすいのはアイコン側です。メニュー本体だけを対象にすると、パネルは瞬時に切り替わるのにハンバーガーの3本線だけが回転し続けます。セレクタは本体とアイコンの両方を並べて書きます。詳細度が本体のルールに負けると効かないので、同じセレクタで上書きするのが確実です。詳しい挙動はMDNの prefers-reduced-motion(2026年8月2日取得)にあります。
自分の実装を検査して合否を判定する
ここからは、あなた自身のページに対して行う検査です。判定はすべてDOMから値を読んで行います。開発者ツールのパネルの見え方は根拠にしません。パネルの表示は条件が揃ったものだけを返す仕組みなので、「グレーで出ているから効いていない」といった読み方は合否ラインになりません。
| 確かめること | 実行するコード | 合格 | 外れたとき |
|---|---|---|---|
| キーボードで開けるか | document.activeElement.tagName | "BUTTON" | divならbuttonへ置き換える |
| 開閉状態が伝わるか | btn.getAttribute('aria-expanded') | "false"から"true" | setAttribute を追加する |
| 閉時にフォーカスが入らないか | a.focus() の後に activeElement を比較 | すべて false | inert を付ける |
| 動きを減らす設定が効くか | getComputedStyle(el).transitionDuration | "0s" | メディアクエリのセレクタを見直す |
キーボードだけでメニューを開けるか
ページを読み込み、アドレスバーをクリックしてからTabを押していきます。ハンバーガーにフォーカスが来たらEnterを押し、いったん閉じてSpaceでも試します。判定はコンソールで行います。
console.log(document.activeElement.tagName);
console.log(document.querySelector('.hamburger').getAttribute('aria-expanded'));
- 合格:1行目が
"BUTTON"になり、EnterとSpaceの両方で2行目が"false"と"true"を往復する - Tabで到達しない:トリガーが
<div>か<span>。<button type="button">に置き換える。tabindex="0"を足すだけではEnterとSpaceが効かない - 到達するがEnterで開かない:
<a href="#">か<div tabindex="0">になっている。<button>にする - 開くが2行目が
null:JS側でsetAttribute("aria-expanded", ...)を呼んでいない。クラスの付け外しだけで管理している状態
閉じているメニューにフォーカスが入らないか
メニューを閉じた状態でコンソールに貼ります。実際にフォーカスを当ててみて、当たったかどうかを比較する方法です。
document.querySelectorAll('#menu a').forEach((a) => {
a.focus();
console.log(a.textContent.trim(), document.activeElement === a);
});
- 合格:すべての行が
false - 1つでも
true:閉じているのにキーボードで到達できる。画面外に出しているだけの状態なので、メニュー要素にinertを付け、開くときに外す inertを付けたのにtrue:ブラウザが対応していない可能性がある。document.body.inert !== undefinedを実行してfalseが返るなら未対応なので、visibility: hiddenで代替する
開閉状態が支援技術に伝わっているか
開く前と開いた後の2回実行して、値が変わるかを見ます。
const btn = document.querySelector('.hamburger');
const menu = document.getElementById('menu');
console.table({
role: btn.tagName === 'BUTTON' ? 'button' : btn.getAttribute('role'),
name: btn.getAttribute('aria-label') || btn.textContent.trim(),
expanded: btn.getAttribute('aria-expanded'),
menuInert: menu.inert
});
- 合格:
roleがbutton、nameが空でない、expandedがfalseからtrueへ、menuInertがtrueからfalseへ変わる nameが空:ボタンの中身が装飾用の要素だけで読み上げるテキストがない。aria-labelを付けるexpandedがnullのまま:属性を更新していない。setAttributeを追加するroleがnull:<div>のまま。最初の手順に戻る
動きを減らす設定でアニメーションが止まるか
OSの設定を有効にします。macOSはシステム設定のアクセシビリティからディスプレイを開いて「視差効果を減らす」、Windowsは設定のアクセシビリティから視覚効果を開いて「アニメーション効果」をオフにします。設定したうえで次を実行します。
console.log(matchMedia('(prefers-reduced-motion: reduce)').matches);
console.log(getComputedStyle(document.getElementById('menu')).transitionDuration);
console.log(getComputedStyle(document.querySelector('.hamburger span')).transitionDuration);
- 合格:1行目が
trueのとき、2行目と3行目がどちらも"0s" - 1行目が
false:OS設定が反映されていない。ブラウザを再起動する。ここがfalseのままだと以降は判定できない - 1行目が
trueなのに"0.5s"のまま:メディアクエリのブロックが無いか、詳細度が本体のルールに負けている。同じセレクタで上書きする - 2行目は
"0s"だが3行目が"0.3s":メディアクエリ内にアイコン側のセレクタを書き漏らしている
つまずきやすいポイント
| 症状 | 原因 | 直し方 |
|---|---|---|
| 綴じ目が表示されない | content の指定漏れ | content: ""; を足す |
| 閉じているのにTabで通過する | 画面外に出しているだけ | inert を付け、開くときに外す |
| アイコンが×にならない | transition だけ書いて変形ルールが無い | [aria-expanded="true"] で transform を指定する |
| 小型タブレットで画面が埋まる | 幅が 600px 固定 | width: min(600px, 80vw) にする |
| 変数を宣言していないのに動く | id が同名のグローバル変数として参照できてしまう | const で明示的に宣言する |
最後の行は補足です。id を持つ要素は同名のプロパティとして window に生えるため、const hamburger = ... を書かなくてもコードは動いてしまいます。これは HTML仕様の named access on the Window object(2026年8月2日取得)で定められた挙動で、エラーにはなりません。ただし同名の変数と衝突したときに追いにくいので、宣言は省略しないでください。
幅の固定については、実測で分かりやすい差が出ました。width: 600px と @media (max-width: 600px) の組み合わせだと、ビューポート幅601pxのときメニューが画面の99.8%を占めます。min(600px, 80vw) にすると、601pxでは481px、375pxでは300pxと、どの幅でも画面の80%に収まります。
よくある質問(FAQ)
Q. ノートの罫線はCSSだけで作れますか?
作れます。repeating-linear-gradient に色停止位置を3つ指定すれば、画像を使わずに等間隔の罫線が引けます。repeating-linear-gradient(#f7f1aa, #fff9b3 20px, #c1c1c1 21px) なら21pxで1周し、20pxぶんが紙の面、残りの1pxぶんが線になります。行間を変えたいときは2つ目と3つ目の数値を同じだけずらし、線を太くしたいときは2つの差を広げます。
Q. 疑似要素で作った綴じ目が表示されないのはなぜですか?
content の指定漏れがほぼ確実です。content の初期値は normal で、::before と ::after ではこれが none に計算されるため、疑似要素そのものが生成されません。content: ""; を1行足せば表示されます。確認は getComputedStyle(el, '::before').content を実行し、"none" が返るかどうかで判定できます。
Q. ハンバーガーは div と button のどちらで作るべきですか?
<button type="button"> を使ってください。<div> はTabキーでフォーカスを受け取らず、EnterやSpaceでも反応しないため、キーボードだけでメニューを開く手段が無くなります。WAI-ARIA Authoring Practices の Disclosure パターンも、開閉を制御する要素に button のロールを求めています。<button> にすれば、フォーカス・キー操作・ロールが標準で付いてきます。
Q. メニューを画面外に飛ばせば非表示にできたことになりますか?
なりません。right: -100% や transform: translateX(100%) で画面の外に出しても要素は表示状態のままなので、キーボード利用者は見えないリンクをTabで通過することになります。メニュー要素に inert を付けて開くときに外すか、visibility: hidden を併用して、閉じている間はフォーカスの対象から外してください。
Q. aria-controls は必ず指定しないといけませんか?
必須ではありません。WAI-ARIA Authoring Practices の Disclosure パターンでは aria-expanded が必須で、aria-controls は任意と位置づけられています。ただし指定しておくとボタンと開閉対象の対応関係が支援技術に伝わるため、対象の要素に id があるなら付けておくとよいです。フォーカスの閉じ込めも、このパターンでは要求されていません。
Q. 動きを減らす設定にはどう対応しますか?
@media (prefers-reduced-motion: reduce) の中で、対象要素の transition を none にします。効いているかは、OS設定を有効にしたうえで matchMedia('(prefers-reduced-motion: reduce)').matches が true を返し、かつ getComputedStyle(要素).transitionDuration が "0s" になることで確認できます。アイコン側のセレクタを書き漏らすとアイコンだけ動き続けるので、メニューとアイコンの両方を対象に含めてください。
まとめ
- ノートの見た目は
repeating-linear-gradientと::beforeで作れる。綴じ目が出ないときはcontentの指定漏れを疑う - 開閉は
<button type="button">とaria-expanded、閉じている間のinertの3点で通る。フォーカスの閉じ込めは要らない - 直ったかどうかは見た目ではなく
getComputedStyleとgetAttributeの戻り値で判定する
同じハンバーガーメニューでも、見た目の方向性を変えると印象は大きく変わります。円形に開くタイプは円形に展開するハンバーガーメニューの実装ガイドにまとめてあります。ヘッダー全体の設計から見直したい場合はナビゲーションメニューの設計と最適化もあわせて読んでみてください。
アイコンの変形や開閉パターンを見比べてから決めたい場合は、ブラウザ上で組み合わせを試せるツールを用意しています。
既存サイトのナビゲーションを、キーボード操作の検査まで含めて作り直したい場合は、制作の相談も受け付けています。
