「ハンバーガーメニューは作れるけど、実案件で求められるドロワーメニューはどう実装すればいい?」——そんな疑問を抱えたことはありませんか?

この記事では、実案件でよく目にする「右からスライドして出てくるドロワーメニュー」の作り方を解説します。ポイントは3つ。背景を暗くするオーバーレイ、ページスクロールを止めるスクロールロック、そしてキーボード操作をメニュー内に閉じ込めるフォーカストラップ。これらを組み合わせることで、デザインの完成度もアクセシビリティの品質も、一段上がります。

実際の動作はこちら

🌐 デモを見る(GitHub Pages)

💻 ソースコード全文(GitHub)

コード全文はGitHubリポジトリで公開しているので、本記事では「動作の核」になる部分だけを抜粋して解説していきます。全コードを頭に入れる必要はありません。なぜこう書くのかを理解して、安心してコピペできるようになることがゴールです。


このスニペットで作れるもの

スマホ幅で3本線のハンバーガーボタンをタップすると、右から白いパネルがスライドして出てきます。同時に背景が半透明の暗幕で覆われ、「メニューが開いた」という状態が視覚的にわかりやすく伝わります。768px以上ではハンバーガーボタンが消え、通常の横並びナビゲーションに切り替わります。

動作の特徴をまとめます。

  • 右からスライドインするドロワーメニュー
  • 背景に半透明のオーバーレイ(暗幕)が表示される
  • オーバーレイをクリックするとメニューが閉じる
  • ドロワー内に「×」の閉じるボタンあり
  • Escapeキーでメニューを閉じられる
  • メニュー開放中はページがスクロールしない(スクロールロック)
  • Tabキーのフォーカスがドロワー内でループする(フォーカストラップ)
  • 768px以上でPCナビに切り替わり、ドロワーは非表示
  • aria属性によるアクセシビリティ対応

実際の動きはGitHub Pagesのデモで触れます。スマホサイズに画面を縮めて、ボタンタップ→ドロワー展開→オーバーレイクリックで閉じる、という一連の流れを確認してから読み進めると理解がスムーズです。


HTMLの構造を見てみよう

HTMLの肝は、ヘッダーの外にオーバーレイドロワー本体という2つの要素が追加されていることです。ここでは最小限の抜粋で構造を確認します(リンク項目などを含む完全版はGitHubリポジトリで公開しています)。

<!-- ハンバーガーボタン -->
<button
  class="c-hamburger"
  type="button"
  aria-label="メニューを開く"
  aria-expanded="false"
  aria-controls="c-drawer"
>
  <span class="c-hamburger__line"></span>
  <span class="c-hamburger__line"></span>
  <span class="c-hamburger__line"></span>
</button>

<!-- オーバーレイ(ドロワー開放時に背景を暗くする) -->
<div class="c-overlay" aria-hidden="true"></div>

<!-- ドロワー(右からスライドインするナビゲーション) -->
<nav
  class="c-drawer"
  id="c-drawer"
  aria-label="ドロワーメニュー"
  aria-hidden="true"
>
  <button class="c-drawer__close" type="button" aria-label="メニューを閉じる">
    <span class="c-drawer__close-icon" aria-hidden="true"></span>
  </button>
  <ul class="c-drawer__list">
    <li class="c-drawer__item"><a href="#" class="c-drawer__link">TOP</a></li>
    <!-- 他のリンクは省略 -->
  </ul>
</nav>

注目してほしいのは、ヘッダーの外に .c-overlaynav.c-drawer という2つの要素があること。この2つがこのスニペットの核になります。

ドロワーとオーバーレイを別要素にする理由

オーバーレイとドロワーを1つの要素にまとめることもできますが、このスニペットでは意図的に分けています。理由はz-indexの制御にあります。

要素z-index役割
.c-header(ヘッダー)20ページ最上部に常に表示される土台
.c-overlay(暗幕)30ヘッダーの上に被さる暗幕
.c-drawer(ドロワー)40暗幕のさらに上に浮かぶメニューパネル

ヘッダーの上に暗幕が被さり、その暗幕のさらに上にドロワーが浮かぶ。この積み重ね順を実現するために、要素を分けています。1つにまとめると、この3層の制御が難しくなります。

aria属性の役割

aria属性は、スクリーンリーダーなどの支援技術に対して「この要素が今どんな状態か」を伝えるための属性です。このスニペットで使っているaria属性の役割をまとめます。

属性役割
aria-label="メニューを開く"ボタンの名前をスクリーンリーダーに伝える(ボタン内にテキストがないため)
aria-expanded="false"メニューが閉じていることを示す(開いたとき "true" に変わる)
aria-controls="c-drawer"このボタンが操作する要素のIDを示す(id="c-drawer" と対応)
aria-hidden="true"(ドロワー側)初期状態でドロワーは画面外に隠れているため、スクリーンリーダーには存在しないものとして扱う。開いたときにJSで "false" に切り替える
aria-hidden="true"(オーバーレイ側)HTML側で固定し、JSからは操作しない。視覚的な暗幕で、スクリーンリーダーには関係のない装飾要素だから

ちなみにPC用の .c-nav(ヘッダー内の横並びナビ)には aria-hidden を付けていません。PCナビは768px以上では常に表示される要素なので、隠す必要がないからです。aria-hidden は「視覚的に隠している要素」に付けるもの、と覚えておくと混乱しません。


CSSでオーバーレイとドロワーを実装する

CSS全文は長いため、GitHubリポジトリのほうに譲り、ここでは「動きの核」になるポイントだけを抜粋します。

なお、CSS全文を覗くと margin-blockpadding-inline といった見慣れない書き方が出てきます。これは「論理プロパティ」と呼ばれる比較的新しいCSSの書き方で、従来の margin-top / margin-bottompadding-left / padding-right の一括指定に相当します。横書きの日本語サイトであれば、動作は従来のプロパティと同じです。「最近のコードでよく見かける書き方」として覚えておけば問題ありません。

オーバーレイ: opacity + visibility + pointer-events でフェード

オーバーレイの表示・非表示には display: none ではなく、3つのプロパティを組み合わせています。display: none だと瞬時に要素が消えてしまい、なめらかなフェードが作れないからです。

.c-overlay {
  position: fixed;
  inset: 0;
  z-index: var(--z-overlay);
  background-color: rgba(0, 0, 0, 0.5);
  opacity: 0;
  visibility: hidden;
  pointer-events: none; /* 非表示中は誤クリックを防ぐ */
  transition:
    opacity 0.3s ease,
    visibility 0.3s ease;
}

.c-overlay.is-visible {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

ポイントは3つのプロパティが同時に切り替わること。

プロパティ非表示時表示時
opacity0(透明)1(不透明)
visibilityhidden(非表示)visible(表示)
pointer-eventsnone(クリック無効)auto(クリック有効)

visibility: hidden だけでは、見た目は消えても「クリックを受け取る」状態のままです。pointer-events: none を合わせることで、非表示中にオーバーレイが誤クリックされるのを防いでいます。目には見えないけれど、クリックが透過している状態です。

ドロワー: transform: translateX でスライドイン

ドロワーは transform: translateX(100%) で初期状態を画面の右外に待機させています。100% はドロワー自身の幅分だけ右にずらす、という意味です。

.c-drawer {
  position: fixed;
  top: 0;
  right: 0;
  z-index: var(--z-drawer);
  width: 280px;
  height: 100%;
  background-color: #fff;
  transform: translateX(100%); /* 初期状態: 画面右外に待機 */
  transition: transform 0.3s ease;
}

.c-drawer.is-open {
  transform: translateX(0); /* is-open で画面内にスライドイン */
}

is-open クラスが付くと translateX(0) になり、右から左へスライドインします。transition によってこの変化がアニメーションとして描画されます。

×(バツ)変形アニメーションの仕組み

ハンバーガーボタンのアニメーションは、3本線それぞれに transform をかけて×形に組み替えるシンプルな作りです。1本目を translateY(7px) rotate(45deg) で下に移動+回転、2本目を opacity: 0 で非表示、3本目を translateY(-7px) rotate(-45deg) で上に移動+回転。7px は線の高さ(2px)と線間のgap(5px)の合計で、この距離だけ動かすと中央で×形に交差します。詳細はCSS全文(コピペ用セクション)をご確認ください。

PCサイズでの切り替え

768px以上ではドロワーを display: none で非表示にし、ヘッダー内の .c-nav(横並びナビ)を display: block で表示します。ハンバーガーボタンも同様に display: none で非表示になります。


JavaScriptの実装を見てみよう

JavaScriptは役割ごとに関数を分けて書いています。全文はGitHubリポジトリにあります。ここでは核となる openMenu / closeMenu、そしてドロワーメニューならではの スクロールロックフォーカストラップ に絞って解説します。

openMenu / closeMenu の役割

openMenucloseMenu を別関数にしている理由は、「オーバーレイクリックで閉じる」「×ボタンで閉じる」「Escapeキーで閉じる」など、複数の場所から closeMenu() を呼び出す必要があるためです。まとめておくことで、コードの重複をなくせます。

ドロワーメニューで必要になる処理は主に3つです。

  • オーバーレイのクラス切替: overlay.classList.add/remove("is-visible")
  • スクロールロック: document.body.style.overflow = "hidden" / ""
  • フォーカス移動: 開いたときにドロワー内の最初のフォーカス可能要素にフォーカスを移す

抜粋するとこんな形です(クラス切替やaria属性の更新は省略)。

function openMenu() {
  // ... クラス切替・aria属性更新 ...
  overlay.classList.add("is-visible");

  // スクロールロック
  document.body.style.overflow = "hidden";

  // ドロワー内の最初のフォーカス可能要素へフォーカス
  const firstFocusable = drawer.querySelectorAll(FOCUSABLE_SELECTOR)[0];
  if (firstFocusable) {
    setTimeout(function () {
      firstFocusable.focus();
    }, 300); // transition(0.3s)に合わせる
  }
}

function closeMenu() {
  // ... クラス切替・aria属性更新 ...
  overlay.classList.remove("is-visible");

  // スクロールロック解除
  document.body.style.overflow = "";
}

これらを openMenu / closeMenu にまとめることで、どこからメニューを閉じても(×ボタン・オーバーレイクリック・Escapeキー)同じ処理が走ります。ポイントは、クラスの切り替えだけでなくaria属性もセットで更新すること。見た目だけでなく、スクリーンリーダーに対しても「今メニューが開いている/閉じている」を正確に伝えるのが、このコードの肝です。

スクロールロックの仕組み

overflow: hidden をbodyに指定すると、はみ出したコンテンツが隠れます。結果としてページ全体のスクロールが止まる、という仕組みです。空文字 "" を代入すると、インラインスタイルが除去されてデフォルト状態に戻ります。

メニューが開いているのにバックグラウンドのページがスクロールできてしまうと、ユーザーが「今どこにいるのか」が分かりにくくなります。実案件ではほぼ必須の実装です。私も最初のドロワー実装で「スクロールできちゃう」問題に気づいてから、必ずセットで書くようにしています。

コラム: iOSではスクロールロックが効きにくい

実は document.body.style.overflow = "hidden" は iOS Safari では完全にスクロールを止められないケースがあります。より堅牢にしたい場合は、bodyposition: fixed; top: -{現在のscrollY}px を当てて閉じるときに元の位置に戻す方式や、overscroll-behavior: contain の併用を検討してみてください。本記事ではまず仕組みを理解することを優先し、シンプルな実装を採用しています。

フォーカストラップとは?

フォーカストラップは、キーボードのフォーカス(Tabキーで移動する点線の枠)をドロワーの中だけに閉じ込める仕組みです。ドロワーが開いている状態でTabキーを押し続けると、通常は背景のコンテンツにもフォーカスが移ってしまいます。スクリーンリーダーや完全なキーボード操作のユーザーには、ドロワーの外にフォーカスが逃げると操作が混乱します。WCAGのアクセシビリティ要件でも推奨されている実装です。

核になる関数はこれだけです。

function trapFocus(e) {
  if (e.key !== "Tab") return;
  if (!drawer.classList.contains("is-open")) return;

  const focusableElements = Array.from(drawer.querySelectorAll(FOCUSABLE_SELECTOR));
  if (focusableElements.length === 0) return;

  const firstElement = focusableElements[0];
  const lastElement = focusableElements[focusableElements.length - 1];

  if (e.shiftKey) {
    // Shift+Tab で最初の要素から後退 → 最後の要素へ
    if (document.activeElement === firstElement) {
      e.preventDefault();
      lastElement.focus();
    }
  } else {
    // Tab で最後の要素から前進 → 最初の要素へ
    if (document.activeElement === lastElement) {
      e.preventDefault();
      firstElement.focus();
    }
  }
}

FOCUSABLE_SELECTOR はリンク・ボタン・入力欄など、フォーカスを受け取れる要素をまとめたセレクター文字列です。これで「×ボタン → TOP → ABOUT → … → CONTACT → (最初の×ボタンに戻る)」というループが実現します。

ポイントは「ドロワーが開いているときだけ」発動する点です。閉じている状態では通常のTab移動を妨げません。

setTimeout でフォーカス移動を遅らせている理由

openMenusetTimeout(..., 300) を使っているのも、一見地味ですが重要なポイントです。ドロワーが開いた瞬間にいきなり focus() を呼ぶと、スライドインのアニメーション中にブラウザがフォーカス対象の位置までスクロールしようとして、アニメーションがカクついたり、意図しないスクロールが起きることがあります。

そのため、CSSの transition(0.3秒)が終わってから focus() するよう、setTimeout で300ms待たせています。ちょっとした気配りですが、実案件ではこの3桁ミリ秒の差が体感の品質を決めます。

Escapeキー・リサイズ対応

閉じるイベントとして、Escapeキーとリサイズ対応も実装しています。

  • Escapeキー: document.addEventListener("keydown", ...)e.key === "Escape" を検知して closeMenu() を呼ぶ。閉じた後は hamburger.focus() でフォーカスをボタンに戻す
  • リサイズ対応: window.matchMedia("(min-width: 768px)") で画面幅の変化を検知し、PCサイズに広がったタイミングでドロワーが開きっぱなしになるのを防ぐ

「閉じた後に hamburger.focus() を呼ぶ」というのは小さなポイントですが、キーボード操作のユーザーにとっては「フォーカスが迷子になる」のを防ぐ大事な配慮です。押したボタンのところにフォーカスを戻す、という自然な流れを意識しています。


コピペで使えるソースコード全文

HTML・CSS・JSの全文は、GitHubリポジトリで公開しています。そのままコピペして使える状態で置いていますので、手元のプロジェクトに取り込んでお使いください。

コピペ用:HTML / CSS / JS 全文

ここからコピペで使える完全なソースコードを掲載しています。GitHubを使わない方はこちらをそのままコピーしてください。HTML・CSS・JSの3ファイル構成で、そのまま動作します。

HTML(index.html)

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>002_hamburger-overlay</title>
    <link rel="stylesheet" href="./assets/css/style.css" />
  </head>
  <body>
    <header class="c-header">
      <div class="c-header__inner">
        <!-- ロゴ -->
        <a href="#" class="c-header__logo">LOGO</a>

        <!-- ハンバーガーボタン(モバイルのみ表示) -->
        <button
          class="c-hamburger"
          type="button"
          aria-label="メニューを開く"
          aria-expanded="false"
          aria-controls="c-drawer"
        >
          <span class="c-hamburger__line"></span>
          <span class="c-hamburger__line"></span>
          <span class="c-hamburger__line"></span>
        </button>

        <!-- ナビゲーション(PC: 横並び表示) -->
        <nav class="c-nav" aria-label="グローバルナビゲーション">
          <ul class="c-nav__list">
            <li class="c-nav__item"><a href="#" class="c-nav__link">TOP</a></li>
            <li class="c-nav__item"><a href="#" class="c-nav__link">ABOUT</a></li>
            <li class="c-nav__item"><a href="#" class="c-nav__link">WORKS</a></li>
            <li class="c-nav__item"><a href="#" class="c-nav__link">BLOG</a></li>
            <li class="c-nav__item"><a href="#" class="c-nav__link">CONTACT</a></li>
          </ul>
        </nav>
      </div>
    </header>

    <!-- オーバーレイ(モバイル: ドロワー開放時に背景を暗くする) -->
    <div class="c-overlay" aria-hidden="true"></div>

    <!-- ドロワー(モバイル: 右からスライドインするナビゲーション) -->
    <nav
      class="c-drawer"
      id="c-drawer"
      aria-label="ドロワーナビゲーション"
      aria-hidden="true"
    >
      <!-- ドロワー閉じるボタン -->
      <button
        class="c-drawer__close"
        type="button"
        aria-label="メニューを閉じる"
      >
        <span class="c-drawer__close-icon" aria-hidden="true"></span>
      </button>

      <ul class="c-drawer__list">
        <li class="c-drawer__item"><a href="#" class="c-drawer__link">TOP</a></li>
        <li class="c-drawer__item"><a href="#" class="c-drawer__link">ABOUT</a></li>
        <li class="c-drawer__item"><a href="#" class="c-drawer__link">WORKS</a></li>
        <li class="c-drawer__item"><a href="#" class="c-drawer__link">BLOG</a></li>
        <li class="c-drawer__item"><a href="#" class="c-drawer__link">CONTACT</a></li>
      </ul>
    </nav>

    <script src="./assets/js/script.js" defer></script>
  </body>
</html>

CSS(assets/css/style.css)

/* ================================================
   カスタムプロパティ
   ================================================ */
:root {
  --header-height-sp: 60px;
  --header-height-pc: 72px;
  --drawer-width: 280px;
  --overlay-color: rgba(0, 0, 0, 0.5);
  --drawer-transition: 0.3s ease;

  /* ===== z-index 階層管理 ===== */
  --z-header:  20; /* ヘッダー背景 */
  --z-overlay: 30; /* オーバーレイ(半透明背景) */
  --z-drawer:  40; /* ドロワー */
}

/* ================================================
   c-header: ヘッダー全体
   ================================================ */
.c-header {
  position: sticky;
  top: 0;
  z-index: var(--z-header);
  background-color: #fff;
  border-bottom: 1px solid #e0e0e0;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}

.c-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  max-width: calc(1200px + 20px * 2);
  margin-inline: auto;
  padding-inline: 20px;
  height: var(--header-height-sp);
}

@media (min-width: 768px) {
  .c-header__inner {
    padding-inline: 40px;
    height: var(--header-height-pc);
  }
}

.c-header__logo {
  font-size: 20px;
  font-weight: bold;
  color: #333;
  text-decoration: none;
  letter-spacing: 0.05em;
}

@media (min-width: 768px) {
  .c-header__logo {
    font-size: 24px;
  }
}

/* ================================================
   c-hamburger: ハンバーガーボタン
   ================================================ */
.c-hamburger {
  /* ボタンリセット */
  appearance: none;
  background: none;
  border: none;
  cursor: pointer;
  padding: 8px;
  margin: -8px;

  /* 3本線を縦に並べるレイアウト */
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 5px;
  width: 44px;
  height: 44px;
}

@media (min-width: 768px) {
  /* PCでは非表示 */
  .c-hamburger {
    display: none;
  }
}

/* ハンバーガーの1本線 */
.c-hamburger__line {
  display: block;
  width: 24px;
  height: 2px;
  background-color: #333;
  border-radius: 2px;
  transform-origin: center;
  transition:
    transform 0.3s ease,
    opacity 0.3s ease;
}

/* ×(バツ)アニメーション */
/* is-active クラスが付いたとき、3本線 → ×に変形 */

/* 1本目: 右上がりの斜線 */
.c-hamburger.is-active .c-hamburger__line:nth-child(1) {
  /* 線の高さ(2px) + gap(5px) = 7px(中央線との距離) */
  transform: translateY(7px) rotate(45deg);
}

/* 2本目: 非表示 */
.c-hamburger.is-active .c-hamburger__line:nth-child(2) {
  opacity: 0;
}

/* 3本目: 右下がりの斜線 */
.c-hamburger.is-active .c-hamburger__line:nth-child(3) {
  /* 線の高さ(2px) + gap(5px) = 7px(中央線との距離) */
  transform: translateY(-7px) rotate(-45deg);
}

/* ================================================
   c-nav: PCナビゲーション(横並び)
   ================================================ */
.c-nav {
  /* モバイル: 非表示 */
  display: none;
}

@media (min-width: 768px) {
  /* PC: 常に表示(横並び) */
  .c-nav {
    display: block;
  }
}

.c-nav__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

@media (min-width: 768px) {
  .c-nav__list {
    display: flex;
    gap: 32px;
  }
}

.c-nav__link {
  display: block;
  padding: 0;
  color: #333;
  text-decoration: none;
  font-size: 14px;
  font-weight: 500;
  letter-spacing: 0.05em;
  transition: color 0.2s ease;
}

/* マウス系デバイスかつホバー操作が可能な場合のみ適用(タッチデバイス・ハイブリッドのタッチ操作を除外) */
@media (any-hover: hover) and (pointer: fine) {
  .c-nav__link:hover {
    color: #0066cc;
  }
}

/* ================================================
   c-overlay: オーバーレイ(ドロワー開放時の背景暗幕)
   ================================================ */
.c-overlay {
  /* 初期状態: 非表示 */
  position: fixed;
  inset: 0;
  z-index: var(--z-overlay);
  background-color: var(--overlay-color);
  opacity: 0;
  visibility: hidden;
  pointer-events: none; /* 非表示状態はクリック無効 */
  transition:
    opacity var(--drawer-transition),
    visibility var(--drawer-transition);
}

/* is-visible クラスで表示 */
.c-overlay.is-visible {
  opacity: 1;
  visibility: visible;
  pointer-events: auto; /* 表示中のみクリック有効 */
}

/* ================================================
   c-drawer: ドロワーナビゲーション(右スライドイン)
   ================================================ */
.c-drawer {
  /* 初期状態: 画面右外に待機 */
  position: fixed;
  top: 0;
  right: 0;
  z-index: var(--z-drawer);
  width: var(--drawer-width);
  height: 100%;
  background-color: #fff;
  box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
  transform: translateX(100%);
  transition: transform var(--drawer-transition);

  /* ドロワー内スクロール対応 */
  overflow-y: auto;
}

/* is-open クラスでスライドイン */
.c-drawer.is-open {
  transform: translateX(0);
}

@media (min-width: 768px) {
  /* PCでは非表示 */
  .c-drawer {
    display: none;
  }
}

/* ================================================
   c-drawer__close: ドロワー内の閉じるボタン
   ================================================ */
.c-drawer__close {
  /* ボタンリセット */
  appearance: none;
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;

  /* ドロワー右上に配置 */
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin-inline-start: auto;
  margin-block: 8px; /* ドロワー上端からの縦余白: タッチターゲット確保のため最小 8px */
  margin-inline-end: 12px; /* ドロワー右端からの余白: ドロワー内パディングと視覚的に揃える値 */
}

/* ×アイコン(疑似要素で描画) */
.c-drawer__close-icon {
  position: relative;
  display: block;
  width: 20px;
  height: 20px;
}

.c-drawer__close-icon::before,
.c-drawer__close-icon::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 0;
  width: 100%;
  height: 2px;
  background-color: #333;
  border-radius: 2px;
  transform-origin: center;
}

.c-drawer__close-icon::before {
  transform: translateY(-50%) rotate(45deg);
}

.c-drawer__close-icon::after {
  transform: translateY(-50%) rotate(-45deg);
}

/* マウス系デバイスかつホバー操作が可能な場合のみ適用(タッチデバイス・ハイブリッドのタッチ操作を除外) */
@media (any-hover: hover) and (pointer: fine) {
  .c-drawer__close:hover .c-drawer__close-icon::before,
  .c-drawer__close:hover .c-drawer__close-icon::after {
    background-color: #0066cc;
  }
}

/* ================================================
   c-drawer__list / item / link: ドロワーメニューリスト
   ================================================ */
.c-drawer__list {
  list-style: none;
  margin: 0;
  padding: 0;
  padding-block-start: 8px; /* 閉じるボタン下部とリスト上端の間隔: 視覚的な余白として 8px */
}

.c-drawer__item {
  border-bottom: 1px solid #f0f0f0;
}

.c-drawer__item:last-child {
  border-bottom: none;
}

.c-drawer__link {
  display: block;
  padding: 16px 24px;
  color: #333;
  text-decoration: none;
  font-size: 15px;
  font-weight: 500;
  letter-spacing: 0.05em;
  transition: color 0.2s ease, background-color 0.2s ease;
}

/* マウス系デバイスかつホバー操作が可能な場合のみ適用(タッチデバイス・ハイブリッドのタッチ操作を除外) */
@media (any-hover: hover) and (pointer: fine) {
  .c-drawer__link:hover {
    color: #0066cc;
    background-color: #f5f8ff;
  }
}

JavaScript(assets/js/script.js)

/**
 * 002_hamburger-overlay
 * ドロワー型ハンバーガーメニュー(オーバーレイ付き)
 *
 * 機能:
 * - ハンバーガーボタンクリックで右からドロワーをスライドイン
 * - 背景にオーバーレイ(半透明の暗幕)を表示
 * - ボタンに is-active、ドロワーに is-open、オーバーレイに is-visible クラスを付与/除去
 * - aria-expanded / aria-hidden 属性を連動更新(アクセシビリティ対応)
 * - オーバーレイクリックでドロワーを閉じる
 * - ドロワー内の閉じるボタンで閉じる
 * - Escape キーで閉じる(フォーカスをハンバーガーボタンに戻す)
 * - スクロールロック(メニュー開放中は body のスクロールを止める)
 * - フォーカストラップ(ドロワー内にフォーカスを閉じ込める)
 * - matchMedia でリサイズ時に状態をリセット
 */

(function () {
  "use strict";

  // 要素取得
  const hamburger = document.querySelector(".c-hamburger");
  const drawer = document.querySelector(".c-drawer");
  const drawerClose = document.querySelector(".c-drawer__close");
  const overlay = document.querySelector(".c-overlay");

  // 要素が存在しない場合は処理しない
  if (!hamburger || !drawer || !overlay) return;

  // フォーカス可能な要素のセレクター
  const FOCUSABLE_SELECTOR =
    'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

  /**
   * メニューを開く
   */
  function openMenu() {
    hamburger.classList.add("is-active");
    drawer.classList.add("is-open");
    overlay.classList.add("is-visible");

    // aria 属性更新
    hamburger.setAttribute("aria-expanded", "true");
    hamburger.setAttribute("aria-label", "メニューを閉じる");
    drawer.setAttribute("aria-hidden", "false");
    // overlay の aria-hidden はHTML側で固定(JSからは操作しない)

    // スクロールロック
    document.body.style.overflow = "hidden";

    // ドロワー内の最初のフォーカス可能要素にフォーカス移動
    const firstFocusable = drawer.querySelectorAll(FOCUSABLE_SELECTOR)[0];
    if (firstFocusable) {
      // トランジション完了後にフォーカス移動
      // 300ms = CSS カスタムプロパティ --drawer-transition(0.3s)に合わせること
      // CSS 側の値を変更した場合はここも合わせて変更する
      setTimeout(function () {
        firstFocusable.focus();
      }, 300);
    }
  }

  /**
   * メニューを閉じる
   */
  function closeMenu() {
    hamburger.classList.remove("is-active");
    drawer.classList.remove("is-open");
    overlay.classList.remove("is-visible");

    // aria 属性更新
    hamburger.setAttribute("aria-expanded", "false");
    hamburger.setAttribute("aria-label", "メニューを開く");
    drawer.setAttribute("aria-hidden", "true");
    // overlay の aria-hidden はHTML側で固定(JSからは操作しない)

    // スクロールロック解除
    document.body.style.overflow = "";
  }

  /**
   * メニューのトグル
   */
  function toggleMenu() {
    const isOpen = hamburger.classList.contains("is-active");
    if (isOpen) {
      closeMenu();
    } else {
      openMenu();
    }
  }

  /**
   * フォーカストラップ
   * ドロワー内のフォーカス可能要素の間でフォーカスをループさせる
   * @param {KeyboardEvent} e
   */
  function trapFocus(e) {
    if (e.key !== "Tab") return;
    if (!drawer.classList.contains("is-open")) return;

    const focusableElements = Array.from(drawer.querySelectorAll(FOCUSABLE_SELECTOR));
    if (focusableElements.length === 0) return;

    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    if (e.shiftKey) {
      // Shift + Tab: 最初の要素でさらに後退しようとしたら最後の要素に移動
      if (document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
    } else {
      // Tab: 最後の要素でさらに前進しようとしたら最初の要素に移動
      if (document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
  }

  // ハンバーガーボタンのクリックイベント
  hamburger.addEventListener("click", toggleMenu);

  // ドロワー内の閉じるボタンのクリックイベント
  // drawerClose は省略可能な要素のため、存在する場合のみイベントを登録する(ガード節とは別に個別チェック)
  if (drawerClose) {
    drawerClose.addEventListener("click", function () {
      closeMenu();
      // フォーカスをハンバーガーボタンに戻す
      hamburger.focus();
    });
  }

  // オーバーレイクリックで閉じる
  overlay.addEventListener("click", function () {
    closeMenu();
    // フォーカスをハンバーガーボタンに戻す
    hamburger.focus();
  });

  // キーボード操作(Escape / Tab)を1つのリスナーで統合管理
  document.addEventListener("keydown", function (e) {
    // Escape: メニューを閉じてフォーカスを戻す
    if (e.key === "Escape" && drawer.classList.contains("is-open")) {
      closeMenu();
      hamburger.focus();
      return;
    }
    // Tab: フォーカストラップ
    if (e.key === "Tab") {
      trapFocus(e);
    }
  });

  // 画面幅が768px以上になったらドロワーを閉じる(縦横回転・リサイズ対応)
  // 初期ロード時の aria-hidden 状態は HTML 属性に依存する(リスナーは変化時のみ発火するため)
  const mediaQuery = window.matchMedia("(min-width: 768px)");
  mediaQuery.addEventListener("change", function (e) {
    if (e.matches) {
      // PCサイズ: ドロワーを閉じてスクロールロック解除
      if (drawer.classList.contains("is-open")) {
        closeMenu();
      }
      // PCではドロワーの aria-hidden は不要なので除去
      drawer.removeAttribute("aria-hidden");
    } else {
      // SPサイズに戻ったとき: ドロワーは非表示状態なので aria-hidden を付与
      drawer.setAttribute("aria-hidden", "true");
    }
  });
})();

カスタマイズのポイント

コードをそのまま使いつつ、見た目や動作を調整したい場合は以下の箇所を変更してください。

  • ドロワーの幅: :root--drawer-width: 280px を変更
  • オーバーレイの色・透明度: :root--overlay-color: rgba(0,0,0,0.5) を変更
  • スライドアニメーションの速度: :root--drawer-transition: 0.3s ease を変更(JSの setTimeout300 も同じ値に合わせること)
  • ブレークポイントの変更: CSSのメディアクエリの 768px とJSの matchMedia("(min-width: 768px)") を両方変更する
  • ドロワーを左から開きたい場合: 以下の3点をセットで変更する
    • .c-drawerright: 0left: 0
    • transform: translateX(100%)transform: translateX(-100%)
    • box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12)box-shadow: 4px 0 16px rgba(0, 0, 0, 0.12) に(左から開く場合は影の向きも逆になるので、X軸のオフセット符号を反転させる)

まとめ

この記事では以下の内容を解説しました。

  • 右からスライドインするドロワーメニューの実装
  • opacity + visibility + pointer-events でフェードアニメーションを実現(display: none 不使用)
  • z-indexを3層(ヘッダー20 / オーバーレイ30 / ドロワー40)でカスタムプロパティ管理する設計
  • document.body.style.overflow = "hidden" によるスクロールロック
  • フォーカストラップでキーボード操作をドロワー内に閉じ込める実装

私自身、独立当初は「動くハンバーガーメニュー」までは作れたものの、フォーカストラップやスクロールロックまでは手が回りませんでした。初めてフォーカストラップを実装したときは、どこから手をつけていいか分からず、資料を読み漁って一日溶かした記憶があります。でも一度仕組みを理解してしまえば、次からはコピペ+微調整で済みます。この記事がその「一度理解する」のお手伝いになれば嬉しいです。

まずはGitHub Pagesのデモで動きを触ってみてください。オーバーレイのフェード、ドロワーのスライド、Tabキーでのフォーカスループ——仕組みを知ってから触ると、細部のこだわりが見えてきます。気に入ったらGitHubリポジトリからコードを持ち帰ってカスタマイズしてみてください。

ドロワーメニューはLP・コーポレートサイトなど実案件で定番の実装パターンです。オーバーレイ・スクロールロック・フォーカストラップの3つを押さえておけば、アクセシビリティの面でも安心して納品できます。まずはコピペで動かして、慣れてきたら自分のプロジェクトに合わせてカスタマイズしてみてください。


関連記事

【関連記事】シンプルなハンバーガーメニューの作り方【コピペOK】
001の記事

【関連記事】ナビゲーションメニュー実装パターン5選
→ 内部リンク(まとめ記事が公開されたら設置)★要確認