HTMLとCSSだけのアクセシビリティに配慮したドロワーメニュー

ハンバーガーボタンと呼ばれる、三本線のアイコンを押すと出てくるドロワーメニュー (引き出し型メニュー) の実装で困っていませんか? JavaScript を使って実装するのが一般的ですが、HTML と CSS だけで作れるのであれば、それに越したことはないと思っています。そこで、今回はチェックボックス版とダイアログ版の2種類のドロワーメニューの作り方を紹介します。

よくあるヘッダーに配置されたドロワーメニューを作ります。チェックボックス版とダイアログ版のデザイン的な違いはありません。

ヘッダーにサイト名と横並びのページ一覧が並んでいます。

横並びのページ一覧が折りたたまれてハンバーガーボタンが表示されます。

ハンバーガーボタンが押されて右側からドロワーが出てきます。

チェックボックスを使用したドロワーメニュー

HTMLコード

<header class="_header">
  <h1 class="_header_title"><a href="#">Site name</a></h1>
  <p class="_header_ctrl">
    <!-- ::backdrop擬似要素の代替としてスタイリング -->
    <input type="checkbox" id="header_toggle" aria-controls="header_menu" />
    <!-- ハンバーガーボタンとしてスタイリング -->
    <label for="header_toggle">Display menu</label>
  </p>
  <!-- ドロワーメニューとしてスタイリング -->
  <nav id="header_menu" class="_header_menu">
    <ul>
      <li><a href="#">About</a></li>
      <li><a href="#">Products</a></li>
      <li><a href="#">Topics</a></li>
      <li><a href="#">Profile</a></li>
      <li><a href="#">FAQ</a></li>
      <li><a href="#">Contact</a></li>
    </ul>
    <div class="_header_remote-ctrl" aria-hidden="true">
      <!-- バツボタンとしてスタイリング -->
      <label for="header_toggle"></label>
    </div>
  </nav>
</header>

nav#header_menu がドロワーメニューになります。

ポイントは、nav#header_menuinput#header_togglearia-controls属性 で関連付けして、チェックボックスが nav#header_menu をコントロールするためにあることを明示します。そして、for属性 を使用して input#header_toggle と関連付けした label要素 でチェックボックスを操作し、nav#header_menu の開閉を行います。

label要素 は p._header_ctrldiv._header_remote-ctrl のそれぞれの子要素として2つありますが、div._header_remote-ctrl には aria-hidden属性 が設定されており、スクリーンリーダー等から隠されています。

その理由は、チェックボックスが開いている状態と閉じている状態の2つの状態を持っており、それぞれの label要素 に「開く」と「閉じる」という異なる役割を構造的に割り当てられないためです。そのため、p._header_ctrl の子要素の label要素 に「開く」と「閉じる」の両方を役割を持たせ、div._header_remote-ctrl の子要素の label要素 を隠すことで、スクリーンリーダー等にはチェックボックスと label要素 が 1:1 であるかのように振舞わせます。

CSSコード

._header {
  background: #fff;
  color: #202020;
  box-shadow: 0 1px 4px -2px;
  padding-block: 1rem;
  padding-inline: max(2rem, 50cqi - 30rem);
  display: flex;
    column-gap: 2rem;
    justify-content: space-between;
    align-items: center;
}
._header_title {
  font-size: larger;
  font-weight: bolder;
  margin: 0;

  a {
    color: inherit;

    &:not(:hover, :focus-visible) {
      text-decoration: none;
    }
  }
}
/* ポインティングデバイスではない、もしくは画面幅が 40rem (640px) より大きくない場合 */
@media not ((any-hover: hover) and (pointer: fine) and (width > 40rem)) {
  ._header_ctrl {
    margin: 0;

    /* チェックボックスを ::backdrop擬似要素 の代わりに使用 */
    input {
      appearance: none;
      backdrop-filter: blur(4px);
      background: color-mix(in srgb, transparent, #000 50%);
      margin: 0;
      opacity: 0;
      outline: none;
      position: fixed;
        inset-block: 0 100%;
        inset-inline: 0;
      transition: inset-block 0s calc(1s / 4), opacity calc(1s / 4);
      z-index: 1e5;
    }
    /* ハンバーガーボタン */
    label {
      background:
          linear-gradient(currentcolor 0 0) no-repeat 50% calc(50% - 0.375rem) / 1rem 0.125rem,
          linear-gradient(currentcolor 0 0) no-repeat 50% / 1rem 0.125rem,
          linear-gradient(currentcolor 0 0) no-repeat 50% calc(50% + 0.375rem) / 1rem 0.125rem,
          color-mix(in srgb, transparent, currentcolor 10%);
      block-size: 2rem;
      border-radius: 50%;
      cursor: pointer;
      display: block;
      inline-size: 2rem;
      overflow: hidden;
      padding-block-start: 2rem;

      &:hover {
        background-color: #202020;
        color: #fff;
      }
    }
  }
  /* ドロワーメニュー */
  ._header_menu {
    --inline-size: min(20rem, 100%);
    background: #fff;
    display: flex;
      flex-flow: column;
      row-gap: 2rem;
    inline-size: var(--inline-size);
    overflow: auto;
    overscroll-behavior: contain;
    padding: 2rem;
    position: fixed;
      inset-block: 0;
      inset-inline: auto calc(var(--inline-size) * -1);
    transition: inset calc(1s / 4);
    z-index: calc(infinity);

    ul {
      display: flex;
        flex-flow: column;
        row-gap: 2rem;
      margin: auto;
      padding: 0;
      text-align: center;
      text-wrap: balance;
      text-wrap: pretty;

      li {
        display: block;

        a {
          color: inherit;
          display: inline-block;
          font-weight: bolder;

          &:not(:hover, :focus-visible) {
            text-decoration: none;
          }
        }
      }
    }
  }
  ._header_remote-ctrl {
    inline-size: fit-content;
    margin-inline: auto 0;
    order: -1;
    position: sticky;
      inset-block-start: 0;

    /* バツボタン */
    label {
      background:
          linear-gradient(currentcolor 0 0) no-repeat 50% / 1rem 0.125rem,
          linear-gradient(currentcolor 0 0) no-repeat 50% / 0.125rem 1rem,
          color-mix(in srgb, transparent, currentcolor 10%);
      block-size: 2rem;
      border-radius: 50%;
      cursor: pointer;
      display: block;
      inline-size: 2rem;
      rotate: 45deg;

      &:hover {
        background-color: #202020;
        color: #fff;
      }
    }
  }
  /* チェックボックスが :checked になった、もしくはメニュー内にフォーカスが当たったらドロワーメニューを開く */
  ._header:has(#header_toggle:checked, #header_menu:focus-within) {
    ._header_ctrl input {
      inset-block: 0;
      opacity: 1;
      transition: inset-block 0s, opacity calc(1s / 4);
    }
    ._header_menu {
      inset-inline: auto 0;
    }
  }
  /* メニュー内にフォーカスが当たって開いたら、:checked でないチェックボックスを無効化 */
  ._header:has(#header_menu:focus-within) {
    ._header_ctrl input:not(:checked) {
      pointer-events: none;
    }
  }
  /* チェックボックスにフォーカスが当たったら label要素 にフォーカスリングを表示 */
  ._header:has(#header_toggle:focus-visible) {
    ._header_ctrl label,
    ._header_remote-ctrl label {
      outline: solid medium Highlight;
    }
  }
  /* ドロワーメニューが開いているときはルート要素のスクロールとメニュー以外の操作を抑制 */
  :root:has(#header_toggle:checked, #header_menu:focus-within) {
    overflow: hidden;
    pointer-events: none;
    scrollbar-gutter: stable;

    ._header_ctrl,
    ._header_menu {
      pointer-events: auto;
    }
  }
}
/* ポインティングデバイスである、もしくは画面幅が 40rem (640px) より大きい場合 */
@media (any-hover: hover) and (pointer: fine) and (width > 40rem) {
  /* チェックボックスと label要素 を非表示 */
  ._header_ctrl,
  ._header_remote-ctrl {
    display: none;
  }
  ._header_menu {
    margin: 0;

    ul {
      display: flex;
        column-gap: 2rem;
      margin: 0;
      padding: 0;

      li {
        display: block;
        margin: 0;

        a {
          color: inherit;
          display: inline-block;
          font-weight: bolder;

          &:not(:hover, :focus-visible) {
            text-decoration: none;
          }
        }
      }
    }
  }
}

端末の種類 (ポインティングデバイスとタッチデバイス) と画面幅によって、メニューのドロワー状態と横並び状態を切り替えています。

チェックボックスを使用するメリット

メニューを端末の種類と画面幅によって最適化することにより、SEO的 には良くないとされるコンテンツの重複がありません。

また、ドロワーメニューの非表示状態に display: none; を使用せず、Tabキー による移動を可能にすることも SEO的 には良い方法だと言えます。なぜなら、アクセスした時点で非表示 (display: none;visibility: hidden;) になっている要素は、最初から表示されているものに比べ、価値が低い情報とみなされる可能性があるためです。

チェックボックスを使用するデメリット

1つのメニューを最適化する方法は、CSSコード の複雑さに繋がります。そして、CSSコード の複雑さは中長期に渡ってウェブサイトを運用する際のメンテナンス性の低さに繋がります。

例えば apple のように、多くの企業サイトが1つのメニューを最適化する方法を採用していますが、メニューの内容が変わるたびにメニューそのものが刷新されるケースが多いのは、そのためだと思います。

ダイアログを使用したドロワーメニュー

HTMLコード

<header class="_header">
  <h1 class="_header_title"><a href="#">Site name</a></h1>
  <!-- ポインティングデバイスである、もしくは画面幅が 40rem (640px) より大きい場合に表示 -->
  <nav class="_header_menu">
    <ul>
      <li><a href="#">About</a></li>
      <li><a href="#">Products</a></li>
      <li><a href="#">Topics</a></li>
      <li><a href="#">Profile</a></li>
      <li><a href="#">FAQ</a></li>
      <li><a href="#">Contact</a></li>
    </ul>
  </nav>
  <!-- ポインティングデバイスではない、もしくは画面幅が 40rem (640px) より大きくない場合に表示 -->
  <p class="_header_dialog-show">
    <button type="button" popovertarget="header-dialog" popovertargetaction="show">Open menu</button>
  </p>
  <dialog class="_header-dialog" id="header-dialog" popover="auto">
    <nav class="_header-dialog_menu">
      <ul>
        <li><a href="#">About</a></li>
        <li><a href="#">Products</a></li>
        <li><a href="#">Topics</a></li>
        <li><a href="#">Profile</a></li>
        <li><a href="#">FAQ</a></li>
        <li><a href="#">Contact</a></li>
      </ul>
    </nav>
    <p class="_header-dialog_hide">
      <button type="button" popovertarget="header-dialog" popovertargetaction="hide">Close menu</button>
    </p>
  </dialog>
</header>

popover属性 がある dialog要素 と、 popovertarget属性 と popovertargetaction属性 がある button要素 で作られたドロワーメニューです。チェックボックス版とは異なり、端末の種類・画面幅ごとのメニューを用意します。

CSSコード

._header {
  background: #fff;
  color: #202020;
  box-shadow: 0 1px 4px -2px;
  padding-block: 1rem;
  padding-inline: max(2rem, 50cqi - 30rem);
  display: flex;
    column-gap: 2rem;
    justify-content: space-between;
    align-items: center;
}
._header_title {
  font-size: larger;
  font-weight: bolder;
  margin: 0;

  a {
    color: inherit;

    &:not(:hover, :focus-visible) {
      text-decoration: none;
    }
  }
}
/* 横並びメニュー */
._header_menu {
  margin: 0;

  ul {
    display: flex;
      column-gap: 2rem;
    margin: 0;
    padding: 0;

    li {
      display: block;
      margin: 0;

      a {
        color: inherit;
        display: inline-block;
        font-weight: bolder;

        &:not(:hover, :focus-visible) {
          text-decoration: none;
        }
      }
    }
  }
}
._header_dialog-show {
  margin: 0;

  /* ハンバーガーボタン (開くボタン) */
  button {
    appearance: none;
    background:
        linear-gradient(currentcolor 0 0) no-repeat 50% calc(50% - 0.375rem) / 1rem 0.125rem,
        linear-gradient(currentcolor 0 0) no-repeat 50% / 1rem 0.125rem,
        linear-gradient(currentcolor 0 0) no-repeat 50% calc(50% + 0.375rem) / 1rem 0.125rem,
        color-mix(in srgb, transparent, currentcolor 10%);
    block-size: 2rem;
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: block;
    inline-size: 2rem;
    overflow: hidden;
    padding: 0;
    padding-block-start: 2rem;

    &:hover {
      background-color: #202020;
      color: #fff;
    }
  }
}
/* ドロワーとして機能するダイアログ */
._header-dialog {
  --inline-size: min(20rem, 100%);
  block-size: 100%;
  border: none;
  inline-size: var(--inline-size);
  inset-block: 0;
  inset-inline: auto 0;
  margin: 0;
  overflow: auto;
  padding: 2rem;
  transition:
      display calc(1s / 4) allow-discrete,
      inset calc(1s / 4);

  &::backdrop {
    background: color-mix(in srgb, transparent, #000 50%);
    backdrop-filter: blur(8px);
    opacity: 1;
  }
  /* 開いたときに displayプロパティ を設定 */
  &:popover-open {
    display: flex;
      flex-flow: column;
      row-gap: 2rem;
  }
  /* 閉じた後の状態 */
  &:not(:popover-open) {
    inset-inline: auto calc(var(--inline-size) * -1);
  }
  /* 開く前の状態 */
  @starting-style {
    inset-inline: auto calc(var(--inline-size) * -1);
  }
}
/* ドロワー内に表示されるメニュー */
._header-dialog_menu {
  margin: auto;

  & > ul {
    display: flex;
      flex-flow: column;
      row-gap: 2rem;
    margin: 0;
    padding: 0;
    text-align: center;
    text-wrap: balance;
    text-wrap: pretty;

    li {
      display: block;

      a {
        color: inherit;
        display: inline-block;
        font-weight: bolder;

        &:not(:hover, :focus-visible) {
          text-decoration: none;
        }
      }
    }
  }
}
._header-dialog_hide {
  inline-size: fit-content;
  margin: 0;
  margin-inline: auto 0;
  order: -1;
  position: sticky;
    inset-block-start: 0;

  /* ドロワー内のバツボタン (閉じるボタン) */
  button {
    appearance: none;
    background:
        linear-gradient(currentcolor 0 0) no-repeat 50% / 1rem 0.125rem,
        linear-gradient(currentcolor 0 0) no-repeat 50% / 0.125rem 1rem,
        color-mix(in srgb, transparent, currentcolor 10%);
    block-size: 2rem;
    border: none;
    border-radius: 50%;
    cursor: pointer;
    display: block;
    inline-size: 2rem;
    overflow: hidden;
    padding: 0;
    padding-block-start: 2rem;
    rotate: 45deg;

    &:hover {
      background-color: #202020;
      color: #fff;
    }
  }
}
/* ドロワーが開いているときはルート要素のスクロールとドロワー以外の操作を抑制 */
:root:has(#header-dialog:popover-open) {
  overflow: hidden;
  pointer-events: none;
  scrollbar-gutter: stable;

  ._header-dialog {
    pointer-events: auto;
  }
}
/* ポインティングデバイスではない、もしくは画面幅が 40rem (640px) より大きくない場合 */
@media not ((any-hover: hover) and (pointer: fine) and (width > 40rem)) {
  ._header_menu {
    display: none;
  }
}
/* ポインティングデバイスである、もしくは画面幅が 40rem (640px) より大きい場合 */
@media (any-hover: hover) and (pointer: fine) and (width > 40rem) {
  ._header_dialog-show {
    display: none;
  }
}

それぞれのメニューとドロワー (ダイアログ) が独立しているため、チェックボックス版と比べて入れ子が少ないフラットな CSSコード になっていると思います。

:popover-open擬似クラス と @starting-styleルール の組み合わせによって transitionプロパティ によるアニメーションを実現していますが、2025年9月現在、transitionプロパティ の allow-discrete が FireFox で機能しないため、閉じるアニメーションが適用されことを考慮する必要があります。

また、::backdrop擬似要素 の開く際のアニメーションは @starting-styleルール で追加できますが、閉じる際は擬似要素そのものが取り除かれるためアニメーションは追加できません。

ダイアログを使用するメリット

メニューとドロワーが独立しているため、後から要素を追加するといったことが容易で、メンテナンス性が高いことが挙げられます。そのため、様々な用途で使用される CMS のテンプレートとの相性が良いと思います。

また、フォーム部品のチェックボックスで状態を管理するハック的な方法とは異なり、開く・閉じるに特化している dialog要素 を使用することで、スクリーンリーダーによる読み上げの自然さや、Tabキー による操作のしやすさといった、アクセシビリティが高められる利点もあります。

ダイアログを使用するデメリット

チェックボックス版と異なりコンテンツの重複が前提になるため、SEO的 に良いとは言えませんが、ユーザビリティを高めるために行う実装なので、大きな影響はないと思われます。

また、アニメーションが設定しづらい点も挙げられますが、ブラウザのアップデートによって徐々に解消されつつあります。

CodePen

注: このページに掲載しているコードと CodePen に記述しているコードには若干の違いがあります。

See the Pen CSS-only header drawer menu by nov (@numerofive) on CodePen.

チェックボックス版とダイアログ版、どちらも一長一短で実に悩ましい。ただ、昔に比べて実装方法が増えて、実装しやすくなったことは本当にありがたいことです。

関連記事