模写準中級 #001|ヘッダーナビ(キーボード対応まで)


ヘッダーナビの模写とは、ロゴとナビゲーションを横並びに組み、画面幅が変わっても崩れない構造をHTMLとCSSで再現する練習です。この課題ではそこから一段進めて、閉じたメニューがキーボード操作でどう振る舞うかまで作り込みます。

ヘッダーは見た目だけなら短時間で形になります。それでもWebページの部品のなかでアクセシビリティの差が最も表に出る場所なのは、開閉ボタンや現在地表示といった「状態を持つ要素」が1か所に集中しているからです。見た目の再現で止めるか、状態まで伝えきるかで完成度が分かれます。

この課題の概要は次のとおりです。

項目内容
難易度準中級
所要時間目安2〜3時間(実装量からの見積もりで、計測値ではありません)
使う技術HTML / CSS(Flexbox)/ 開閉を作るなら JavaScript
模写元練習用サンプルサイト elementary001
ゴールマウスでもキーボードでも破綻しないヘッダー

この課題で身につくこと

  • header / nav / ul / li という、ヘッダーの基本骨格の組み立て
  • Flexboxでロゴとナビを横並びにし、上下中央を揃える手順
  • ブラウザ既定値(ulのpaddingとマーカー)の打ち消し方
  • 幅320pxでも横スクロールを出さない幅指定の考え方
  • 開閉するメニューの状態を、見た目以外の手段でも伝える方法

最後の1つが、この課題を「準中級」に置いている理由です。ここまで踏み込むと、実務でそのまま通用するヘッダーになります。


差分チェックツールで効率UPお手本コードと自分のコードを比較して、違いを一目で確認できます。練習前にブックマークしておくと便利です。
Diff Checkerを開く →

模写のお題

次のページのヘッダーを模写してください。2026年8月2日にアクセスして表示を確認しています。

模写サンプルサイト  elementary001

色やフォントを完全一致させる必要はありません。再現するのは構造とレイアウトの考え方です。まずは次の骨格をそのまま書き写すところから始めてください。CSSはまだ書きません。

<header class="site-header">
  <a class="site-header__logo" href="index.html">サイト名</a>

  <button class="site-header__toggle" type="button"
          aria-expanded="false" aria-controls="gnav">メニュー</button>

  <nav id="gnav" class="site-header__nav" aria-label="グローバル">
    <ul>
      <li><a href="index.html" aria-current="page">ホーム</a></li>
      <li><a href="about.html">About</a></li>
      <li><a href="service.html">Service</a></li>
      <li><a href="contact.html">Contact</a></li>
    </ul>
  </nav>
</header>

ここに載っているのは骨格だけで、完成形のスタイルは含めていません。CSSを自分で埋めるところが課題の本体です。なお模写元のサンプルはチェックボックスとlabelで開閉する最小構成で、状態の伝達までは扱っていません。この課題ではそこから一段上げます。


実装の進め方

ステップ1 HTMLだけで骨組みを作る

CSSを1行も書かない状態で、上の骨格を書き終えます。縦に積み上がった素のリストが並ぶだけですが、見た目は一切気にしません。確認するのは「この状態でもリンクを順番にたどれるか」だけです。CSSを当てる前に成立していない構造は、CSSを当てても直りません。

ステップ2 横並びと上下中央を作る

CSSで作るのは、この段階では3点だけです。

  1. headerをFlexコンテナにして、ロゴとナビを横並びにする
  2. nav内のulもFlexコンテナにして、メニュー項目を横並びにする
  3. ロゴとナビの上下位置を揃える

3番目でつまずく人が多いところです。Flexコンテナの子要素は既定で高さいっぱいに伸びるため、ロゴとナビの文字サイズが違うと中心がずれます。子要素の余白を個別に調整するのではなく、親側で中央揃えを指定して解決します(ずれ幅の実測値は後述の「つまずきポイント」に載せています)。

ステップ3 余白と開閉を足す

headerの内側の余白、メニュー項目どうしの間隔、ホバー時の変化を整えます。項目の間隔はmarginを1つずつ足すのではなく、Flexコンテナ側のgapで一括指定すると、項目が増減しても崩れません。最後に狭い画面でのメニュー開閉を作ります。ここが次の章の本題です。


ヘッダーで差がつく4つの要点

以下はすべてMDNの記述を参照し、2026年8月2日にChromium 151(Playwright同梱版)で挙動を確かめたものです。アクセシビリティの全体像はWebアクセシビリティの基本にまとめてあるので、用語がつながらないときはそちらを先に読んでください。

navが複数あるときは名前をつける

nav要素は、ページ内のナビゲーションリンクのまとまりを表す要素です。MDNは「1つの文書が複数のnav要素を持つことがある。たとえばサイト全体のナビゲーションとページ内ナビゲーションである。その場合はaria-labelledbyを使ってアクセシビリティを高められる」と記述しています(MDN: <nav>・2026年8月2日取得)。

見出しがない場所で名前を付けたいときは、参照先を必要としないaria-labelを使います。MDNのaria-currentの解説ページも、パンくずの例で <nav aria-label="Breadcrumb"> と書いています。実際にアクセシビリティツリーを取得すると、名前を付けた2つのnavが「navigation | グローバル」「navigation | パンくず」として区別されて並びました。名前がないとどちらもただの「navigation」になり、読み上げで区別がつきません。逆にnavが1つしかないなら名前は必須ではありません。

開閉ボタンには aria-expanded を持たせる

aria-expanded は、開閉するものを制御する側のコントロールに付ける属性です。MDNは「ウィジェットを切り替えるボタンは、aria-controlsに対象のidを、aria-expandedに現在の状態を設定すべきである」と記述しています(MDN: aria-expanded・2026年8月2日取得)。

ポイントは、付ける先がメニュー本体ではなくボタンのほうだという点です。ハンバーガーボタンを押したときにCSSクラスだけを付け外しする実装は珍しくありませんが、それだと開いているか閉じているかが見た目にしか存在しません。属性を切り替えれば、アクセシビリティツリー上のボタンの状態も expanded=false から expanded=true へ変わることを実測で確認しました。

なおMDNは「aria-expandedがあること自体が『制御している』という意味になる。他の要素の展開状態を制御しない要素には付けないこと」とも注意しています。ナビゲーションのリンクそのものに付けるのは誤りです。

閉じたメニューにフォーカスが入ってしまう問題

ここがこの課題の山場です。メニューを「見た目だけ」隠すと、閉じているのにキーボードのフォーカスが中へ入り込みます。画面には何も見えないのにTabを押すとフォーカスリングが消え、そのあと何度か押さないと本文へ進めません。挙動は隠し方によって分かれます。Chromium 151で、閉じたメニューの中にあるリンクへTabが到達するかを実測した結果が次の表です。

隠し方Tabで入るか備考
display: none入らないレイアウトからも消える
visibility: hidden入らない場所は占有したまま
max-height: 0 と overflow: hidden入る見えないのに操作できる
opacity: 0 や transform で画面外へ入る同上
inert 属性入らない見た目の指定と独立

visibility: hidden について、MDNは「要素ボックスは不可視になる(描画されない)が、レイアウトには通常どおり影響する。要素はフォーカスを受け取れない(Tabによる移動などでも)」と明記しています(MDN: visibility・2026年8月2日取得)。

問題は3番目と4番目です。アニメーションを付けたいときに選ばれやすい隠し方ですが、どちらも要素自体は描画され続けるためフォーカスが到達します。この課題の模写サンプルで確かめたところ、閉じた状態のメニューは高さ0(computed値で max-height: 0px、overflow: hidden、描画高さ0)でしたが、Tabを押すとメニュー内の4つのリンクをすべて順に通過しました。さらに見えないリンクにフォーカスが当たった瞬間、クリップされているはずのコンテナのscrollTopが0から24へ動いていました。これが「画面が勝手にずれる」正体です。

アニメーションを残したまま解決するなら、inert属性が使えます。MDNは「要素がinertのとき、リンク・ボタン・フォームコントロールといった通常は操作可能な要素も含めて、フォーカスもクリックもできなくなる。inertな要素とその子孫は、タブ順序とアクセシビリティツリーから取り除かれる」と記述しています(MDN: inert・2026年8月2日取得)。同ページのBaseline表示は「Widely available」、利用可能になったのは2023年4月とされています。

実際に、先ほどの模写サンプルの閉じたメニューにinertを1つ足しただけで、CSSの隠し方は何も変えないままTabが4つのリンクを飛ばして本文へ抜けるようになりました。開閉ボタン側の処理は次のようになります。閉じている状態をHTMLの初期値として持たせ、クリックのたびに2つの状態を同時に更新します。

const btn = document.querySelector('.site-header__toggle');
const nav = document.getElementById('gnav');

btn.addEventListener('click', () => {
  const willOpen = btn.getAttribute('aria-expanded') !== 'true';
  btn.setAttribute('aria-expanded', String(willOpen));
  nav.inert = !willOpen;
});

見た目の切り替えは、この属性を手がかりにCSS側で書きます。JavaScriptからスタイルを直接書き換えず、属性が状態の唯一の置き場になっている形です。この構成でTabの通り道を実測すると、閉じているときはロゴ → ボタン → 本文、開いているときはボタン → メニューの各リンク → 本文となりました。

現在地は aria-current で示す

ナビゲーションの「今いるページ」だけ色を変える、下線を引く、といった表現はよく使われます。MDNは「1つだけが他と違うスタイルで、これが現在の要素だと視覚的に示されているとき、何がそれを示しているのかを支援技術のユーザーに伝えるためにaria-currentを使うべきである」と記述しています。ページ内の現在地であれば aria-current="page" です(MDN: aria-current・2026年8月2日取得)。

注意点が2つあります。1つは「1組の要素のうち、currentとして印を付けるのは1つだけにする」とMDNが明記していること。もう1つは、pageやstepなど決まった値以外を書いた場合、それは既定のfalseではなく aria-current="true" を書いたものとして扱われる点です。タイプミスが静かに有効になってしまうので、値は必ず確かめてください。見た目は属性セレクタで当てられるため、現在地専用のCSSクラスは不要になります。


完成条件(合格ライン)

次の5項目がすべてOKなら、この課題は完成です。チェックリストではなく、ブラウザで実際に確かめられる判定基準にしてあります。

判定項目OKの状態外れたら疑うこと
閉じたメニューとキーボードメニューが閉じている状態でTabを押し続けると、ロゴ・開閉ボタンを通って本文へ抜ける。メニュー内のリンクを一度も通らないメニューの隠し方が見た目だけ(max-heightやopacityやtransform)になっている
開いたメニューとキーボードボタンをEnterまたはSpaceで押して開いた直後、Tabを押すとメニューの先頭リンクへ進む開閉ボタンがbutton要素でなくdivやspanになっている
状態の一致開いているときaria-expandedが true、閉じているとき falseJavaScriptがCSSクラスだけを切り替えて属性を更新していない
現在地aria-current=”page” がページ内でちょうど1つテンプレートの全リンクに一律で書き込んでいる
狭い画面幅320pxでヘッダーが横スクロールを生まないどこかにpx単位の固定幅が残っている

1つ目と2つ目は目で追うのが大変なので、ブラウザの開発者ツールのコンソールに次を貼ると、フォーカスが移った先が1行ずつ出力されます。あとはTabを押すだけです。

document.addEventListener('focusin', (e) => {
  console.log(
    e.target.tagName,
    e.target.textContent.trim().slice(0, 16),
    'nav内=', !!e.target.closest('nav')
  );
});

3つ目から5つ目は、同じくコンソールに次を貼れば1回で出ます。

console.log({
  expanded: document.querySelector('[aria-expanded]')?.ariaExpanded,
  navNames: [...document.querySelectorAll('nav')].map(n => n.getAttribute('aria-label')),
  currentCount: document.querySelectorAll('[aria-current="page"]').length,
  scrollW: document.documentElement.scrollWidth,
  clientW: document.documentElement.clientWidth,
});

currentCount が1、scrollW と clientW が同じ数字(320で表示しているなら両方320)になっていれば合格です。expanded は開閉に合わせて文字列の “true” と “false” が入れ替わります。なお aria-current は、Chromiumの開発者ツールが返すアクセシビリティツリーの属性一覧には現れませんでした。上の判定でDOM側の値を見ているのはそのためで、ツリーに出ないから書かなくてよいという意味ではありません。


つまずきポイント

実際にコードを書いて再現できたものだけを載せます。数値はいずれもChromium 151・表示幅320pxでの実測値です。

症状原因直し方
メニューの前に黒丸が出て、位置が右へずれるulのブラウザ既定値が残っている。実測で padding-left: 40px、list-style-type: disc、上下margin 16px が付いていたnav内のulに list-style: none と padding: 0、margin: 0 を指定する。指定後は padding-left が 0px になる
ロゴとナビの上下がわずかにずれるFlexコンテナの子要素が既定で高さいっぱいに伸びるため。ロゴ28px・ナビ16pxで中心が9pxずれたheader側に align-items: center を指定する。指定後は両者の中心が同じ位置に揃った
スマホ幅で横スクロールが出るpx単位の固定幅が残っている。width: 1200px を1か所残しただけで、320px表示でも文書の横幅が1200pxのままになったwidth: 100% と max-width の組み合わせに置き換える。置換後は横幅が320pxに収まった
閉じているメニューにフォーカスが入り、画面が勝手にずれる見た目だけで隠しているため、要素は描画され続けている。クリップされたコンテナのscrollTopが0から24へ動いた閉じている間だけ inert 属性を付ける。追加後はTabがメニューを飛ばして本文へ抜けた

次の課題へ

この課題は準中級の1本目です。ヘッダーが組めたら、そのすぐ下に来る大きな見せ場を作ります。模写準中級 #002 ヒーローセクションが次の課題です。ヘッダーとヒーローは上下に並ぶので、この課題のマークアップをそのまま土台に使えます。

準中級が重いと感じたら、初級から順に手を動かすほうが早く進みます。レベル別の一覧は模写コーディング課題一覧にまとめてあるので、他のレベルの課題を見るときはこちらから選んでください。

Flexboxやgapの使い分けでつまずいたときは、CSSテクニックに個別の書き方をまとめています。手が止まった時点で調べるより、ひととおり組んでから読み直すほうが定着します。


よくある質問(FAQ)

Q. ヘッダーは header と nav のどちらで囲めばよいですか?

両方使います。headerでロゴを含むヘッダー全体を囲み、そのなかのナビゲーションリンクのまとまりだけをnavで囲むのが基本形です。MDNはnavについて「主要なナビゲーションリンクのまとまりだけを意図している。すべてのリンクをnavに入れる必要はない」と述べており、フッターのリンク集などはnavで囲まなくてよいとされています。

Q. ナビゲーションに aria-label は必ず付ける必要がありますか?

ページ内にnavが1つだけなら必須ではありません。付けるのは、サイト全体のナビゲーションとパンくずのようにnavが複数あって、どれがどれか区別が必要な場合です。中身のない名前を機械的に足すと、かえって読み上げのノイズになります。

Q. 閉じたメニューを display: none で隠すのは正しいですか?

アニメーションが不要なら、それがいちばん確実です。実測でも display: none と visibility: hidden はどちらもTabでフォーカスが入りませんでした。開閉にスライドやフェードを付けたい場合だけ、要素が描画され続けることによるフォーカスの入り込みが問題になるため、inert属性を併用します。

Q. inert 属性はどのブラウザで使えますか?

MDNのinertのページはBaselineを「Widely available」と表示しており、ブラウザ横断で利用可能になったのは2023年4月とされています(2026年8月2日取得)。ただし同ページには「一部の機能はサポート状況が異なる場合がある」という注記も付いているため、実案件で使う前には対象ブラウザでの動作確認をしてください。

Q. ハンバーガーメニューは JavaScript なしでも作れますか?

作れます。この課題の模写サンプルも、チェックボックスとlabelの組み合わせだけで開閉しています。ただしその方法では、開閉ボタンがbutton要素ではなくlabelになるため aria-expanded を状態に合わせて切り替えられません。まずJavaScriptなしで見た目を再現し、そのあとbutton要素に置き換えて状態の伝達を足す、という順番で進めるのがおすすめです。

Q. 現在地のリンクに aria-current を付けると見た目も変わりますか?

属性を付けただけでは何も変わりません。見た目を変えたい場合は、属性セレクタでスタイルを当てます。現在地専用のCSSクラスを別に用意する必要がなくなるので、クラスと属性がずれる事故も防げます。

Q. 模写は見た目を完全一致させないと意味がないですか?

そうではありません。この課題で再現してほしいのは構造とレイアウトの考え方であり、色やフォントのピクセル単位の一致ではありません。完成条件の5項目を満たしていれば、見た目が多少違っていても課題としては到達しています。