「ハンバーガーメニューを右からスライドインさせて画面全体を覆いたい。でも、ナビが画面いっぱいに広がったらロゴや閉じるボタンが見えなくなるのでは?」——そんな疑問を抱えたことはありませんか?

この記事では、画面右端から左方向に滑り込んで全画面を覆うハンバーガーメニューの作り方を解説します。ポイントは、ナビが全画面を覆ってもヘッダー(ロゴ+ハンバーガー)は常に前面に浮かんだままという設計です。仕組みを支えているのは、z-index の階層をカスタムプロパティで管理する設計と、フォーカストラップにロゴまで含めるアクセシビリティ対応の2つ。CSSとJavaScriptだけで実装できます。

実際の動作はこちら

🌐 デモを見る(GitHub Pages)

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

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

この記事で分かること

  • 右端から左方向に全画面でスライドインするナビゲーションをCSSの transform だけで作る方法
  • カスタムプロパティで z-index の階層を設計し、ナビ展開中もヘッダーを常時前面に浮かべる仕組み
  • padding-top でヘッダーと重ならない余白を確保する設計の理由
  • スクロールロック・Escapeキー・リサイズ対応など、実案件レベルのUXコードをJavaScriptで実装する方法
  • フォーカストラップにヘッダーロゴを含めることでキーボードユーザーがナビ内に戻れるアクセシビリティ設計

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

スマートフォンでハンバーガーボタンをタップすると、画面右端から左方向に向かって白いナビゲーションが滑り込み、画面全体(100vw × 100vh)を覆います。同時にヘッダーは常時前面に浮かんだままで、ロゴと×状態に変形したハンバーガーボタンがいつでも操作できます。768px以上では通常の横並びナビに切り替わり、ハンバーガーボタンとスライドインナビは非表示になります。

動作の特徴をまとめると次のとおりです。

  • 右端から左方向に画面全体を覆うスライドインナビゲーション(100vw × 100vh)
  • ナビ展開中もヘッダー(ロゴ+ハンバーガーボタン)が常時前面に表示される
  • ハンバーガーボタンが3本線→×(バツ)に変形(閉じるボタンを兼ねる)
  • ナビ外クリックで閉じる
  • Escapeキーでメニューを閉じる(フォーカスをハンバーガーボタンに戻す)
  • ナビ展開中はページがスクロールしない(スクロールロック)
  • Tabキーのフォーカスがロゴ+ハンバーガー+ナビ内リンクの3セットでループする(フォーカストラップ)
  • 768px以上でPCナビに切り替わり、ハンバーガーとスライドインナビは非表示
  • aria属性によるアクセシビリティ対応

実際の動きはGitHub Pagesのデモで確認できます。スマホサイズに画面を縮めて、ボタンタップ→ナビ展開→ロゴクリックや×タップで閉じる、という流れを試してから読み進めると、このあとの解説が一気に分かりやすくなりますよ。

HTMLの構造を見てみよう

HTMLの肝は、ヘッダーの中にPCナビを含めつつ、モバイル用のスライドインナビは <header>に独立して配置していることです。ここでは最小限の抜粋で構造を確認します(リンク項目などを含む完全版はGitHubリポジトリで公開しています)。

<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-slidein-nav"
    >
      <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="グローバルナビゲーション">
      <!-- リンク項目は省略 -->
    </nav>
  </div>
</header>

<!-- スライドインナビゲーション(モバイル: 右側から左方向にスライド) -->
<nav
  class="c-slidein-nav"
  id="c-slidein-nav"
  aria-label="スライドインナビゲーション"
  aria-hidden="true"
>
  <ul class="c-slidein-nav__list">
    <li class="c-slidein-nav__item"><a href="#" class="c-slidein-nav__link">TOP</a></li>
    <!-- 他のリンクは省略 -->
  </ul>
</nav>

クラス名のプレフィックスには、FLOCSS(フロックス)というCSS設計ルールを採用しています。c- はComponent(再利用できる部品)を意味します。FLOCSSの詳しい解説はFLOCSSとは?CSS設計ルールをわかりやすく解説をどうぞ。

スライドインナビを <header> の外に置く理由

スライドインナビ(.c-slidein-nav)をあえて <header> の子要素にしていない理由は、z-indexの自由度を確保するためです。

もしナビを <header> の中に置いてしまうと、ナビは親要素である .c-header のスタッキングコンテキスト(重ね順の階層)の中に閉じ込められてしまいます。すると「ヘッダーよりナビを下に置きたい」「ナビよりヘッダーを上に重ねたい」といった指定が自由に効かなくなります。

本スニペットでは「ナビが全画面を覆っても、ヘッダーは常時前面に浮かんでいてほしい」という設計を実現したいので、ナビをヘッダーから切り離して独立したレイヤーに配置しています。さらに position: fixed で画面全体を覆うため、stacking contextの観点からもルート配下(<body> の直下)に置くのが自然です。

「構造の独立性が、z-indexの自由度を生む」と覚えておくと、今後のレイアウト設計でも応用が効きます。

aria属性の役割

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

属性役割
aria-label="メニューを開く" / "メニューを閉じる"ボタンの名前をスクリーンリーダーに伝える(JSで状態に応じて切り替え)
aria-expanded="false" / "true"メニューが今開いているか閉じているかを示す
aria-controls="c-slidein-nav"このボタンが操作対象とする要素のIDを示す
aria-hidden="true" / "false"非表示中はスクリーンリーダーにも要素の存在を隠す(スライドインナビ側に付与)

これらの属性は、JavaScript側で状態に合わせて自動的に切り替えています。コピペするだけで基本的なアクセシビリティ対応がそろう設計になっています。

なお、PC幅(768px以上)ではナビが常時表示される仕様のため、aria-hidden 属性自体を除去しています。「常に見えている要素に aria-hidden="false" を残す必要はない」というのが、後述するJavaScriptのリサイズ処理の考え方です。

CSSで右スライドイン&ヘッダー前面固定を実装する

CSS全文はGitHubリポジトリに譲り、ここでは「動きの核」になるコードだけを抜粋して解説します。

なお、本スニペットでは margin-block / margin-inline / padding-inline といった論理プロパティを使っています。従来の margin-top / margin-bottom / padding-left / padding-right と同じ意味なので、初めて見ても身構えなくて大丈夫です。

カスタムプロパティで z-index 階層を管理する

本スニペットで一番大事な設計は、ここから始まります。z-index を「単なる数字」として扱わず、「どのレイヤーに何を置くか」を :root で先に定義しておくアプローチです。

:root {
  --header-height-sp: 60px;
  --header-height-pc: 72px;
  --slidein-transition: 0.3s ease;

  /* ===== z-index 階層管理 ===== */
  --z-drawer:    40; /* フルスクリーンナビ(右スライドイン) */
  --z-floating:  50; /* フローティング要素(ヘッダー借用用) */
}

.c-header {
  position: sticky;
  top: 0;
  /* ナビ展開時もヘッダーを前面に保つため、通常の --z-header(20) ではなく --z-floating(50) を借用 */
  z-index: var(--z-floating);
  background-color: #fff;
  border-bottom: 1px solid #e0e0e0;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}

ここで注目してほしいのは、ヘッダーに var(--z-floating) を割り当てている点です。普通なら --z-header: 20 のような「ヘッダー専用のレイヤー」を作って割り当てたくなるところですが、本スニペットではあえて --z-floating: 50借りてきています。

理由はシンプルで、スライドインナビが --z-drawer: 40 に居るからです。もしヘッダーが従来通り --z-header: 20 のような下位レイヤーにいると、40 > 20 なのでナビがヘッダーの上にかぶってしまいます。これでは「ナビが全画面を覆っている間もヘッダーを前面に出したい」という設計が成立しません。

そこで、本来は通知バナーやチャットボットのようなフローティング要素に使う --z-floating: 50 のレイヤーを借りてきて、ヘッダーに割り当てています。50 > 40 なので、ナビがどれだけ広がってもヘッダーは常に上にいられる、という関係になります。

z-index を本棚で例えるなら、「何段目に置くか」を後付けで決めるのではなく、「何段目に何を置く本棚にするか」を最初に設計するイメージです。カスタムプロパティで階層を管理しておくと、後から見直したくなったときも :root だけ修正すれば済むので、メンテナンス性も上がります。

スライドインナビは transform: translateX で動かす

ナビ本体は、画面右側の外に待機させておいて、開くタイミングで画面内にスライドさせる仕組みです。

.c-slidein-nav {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh; /* 画面全体を覆う */
  z-index: var(--z-drawer);
  background-color: #fff;

  /* ヘッダー分の余白を確保し、ナビリンクがヘッダーと重ならないようにする */
  padding-top: var(--header-height-sp);

  /* 初期状態: 画面右側外に配置 */
  transform: translateX(100%);
  transition: transform var(--slidein-transition);
}

/* is-open クラスで左にスライドして全画面表示 */
.c-slidein-nav.is-open {
  transform: translateX(0);
}

ポイントは3つあります。

1つ目は position: fixedwidth: 100% / height: 100vh で、画面全体を覆う土台を作っていること。

2つ目は、初期状態の transform: translateX(100%) で、ナビを画面の右側外に待機させていること。100% は要素自身の幅と同じ意味なので、ナビ全体がちょうど画面外に押し出されています。is-open クラスが付くと translateX(0) に変わって、画面内に滑り込んでくる仕組みです。

3つ目は、アニメーションに leftright ではなく transform を使っていること。transform はGPU(グラフィック処理装置)の支援が効くため、left を直接アニメーションするより滑らかに動きます。スマホでもカクつきにくい、という地味だけれど効く違いです。

padding-top でヘッダーと重ならない余白を確保する

地味ですが見落としやすいのが、padding-top: var(--header-height-sp) の行です。

ヘッダーが常時前面に出ているということは、ナビ上部の最初のリンクがヘッダーの真下に潜り込んでしまう可能性があるということです。何もしないと、最初のリンクがヘッダーに隠れてタップできない位置に配置されてしまいます。

そこで、ナビの上部にヘッダーの高さぶんの余白(padding-top)を確保することで、リンクが必ずヘッダーの下から始まるようにしています。たった1行ですが、「見えない場所にリンクが置かれる」というUX事故を確実に防いでくれます。

×(バツ)変形アニメーション

ハンバーガーボタン(3本線)が、ナビ展開時に×に変形する仕組みも見ておきましょう。

.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;
}

/* 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);
}

translateY(7px) の「7px」は、線の高さ(2px)と線の間のgap(5px)を足した値です。1本目の線を中央線の位置までスライドさせたうえで、45度回転させると右上がりの斜線になります。3本目も同じ理屈で、逆方向にスライドして反対の角度に回転しています。2本目は opacity: 0 で消えるだけです。

本スニペットでは、この×ボタンがヘッダー内に常時表示されるという点が地味に効いてきます。ナビが画面いっぱいに広がっても、ユーザーは画面のどこを見ていても「右上の×を押せば閉じられる」と分かるので、UX上の安心感がぐっと上がります。

PC(768px以上)での切り替え

PCサイズではハンバーガーボタンとスライドインナビを display: none にして、PCナビを表示する切り替えです。

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

  /* PCではスライドインナビを非表示 */
  .c-slidein-nav {
    display: none;
  }

  /* PCナビは横並び表示 */
  .c-nav {
    display: block;
  }

  .c-nav__list {
    display: flex;
    gap: 32px;
  }
}

PC幅ではスライドインの仕組み自体が不要なので、丸ごと非表示にしてしまうのがシンプルで分かりやすい方針です。

JavaScriptでトグル動作とアクセシビリティを実装する

JavaScriptもGitHubに全文を置いていますので、ここでは動作の核となる4つの処理だけを抜粋して解説します。

  • メニューを開く・閉じる処理(openMenu / closeMenu
  • スクロールロック
  • フォーカストラップ(ロゴも含めてループさせる)
  • ナビ外クリック・Escapeキー・リサイズ対応

なお、コード全体は即時関数 (function () { ... })() で囲んでいます。これは、関数内で定義した変数がグローバル(window の直下)に漏れないようにするための定番パターンです。他のスクリプトと変数名がぶつかる事故を防げます。

openMenu / closeMenu の処理

メニューの開閉は、シンプルにクラスとaria属性を切り替える設計です。

function openMenu() {
  hamburger.classList.add("is-active");
  slideinNav.classList.add("is-open");

  // aria 属性更新
  hamburger.setAttribute("aria-expanded", "true");
  hamburger.setAttribute("aria-label", "メニューを閉じる");
  slideinNav.setAttribute("aria-hidden", "false");

  // 全画面を覆うためスクロールロック
  lockScroll();

  // スライドインナビ内の最初のフォーカス可能要素にフォーカス移動
  const firstFocusable = slideinNav.querySelectorAll(FOCUSABLE_SELECTOR)[0];
  if (firstFocusable) {
    // トランジション完了後にフォーカス移動
    // 300ms = CSS カスタムプロパティ --slidein-transition(0.3s)に合わせること
    setTimeout(function () {
      firstFocusable.focus();
    }, 300);
  }
}

function closeMenu() {
  hamburger.classList.remove("is-active");
  slideinNav.classList.remove("is-open");

  hamburger.setAttribute("aria-expanded", "false");
  hamburger.setAttribute("aria-label", "メニューを開く");
  slideinNav.setAttribute("aria-hidden", "true");

  unlockScroll();
}

注意してほしいのは setTimeout(function () { ... }, 300)300ms という値です。これはCSSの --slidein-transition: 0.3s意図的に揃えています。スライドインのアニメーションが終わるのを待ってからフォーカスを動かすことで、「アニメーション中にフォーカスが先回りして動いてしまう違和感」を防いでいます。

ここはカスタマイズ時の地雷ポイントです。CSSの --slidein-transition0.5s に変えたら、JavaScript側の setTimeout500 に変更する必要があります。CSSとJavaScriptの両方を必ず一緒に変更してください。片方だけ変えると、フォーカスがアニメーション途中で動いてしまったり、逆に余白の時間ができてしまったりします。

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

ナビが画面全体を覆っている間に背景がスクロールしてしまうと、「自分が今どこにいるのか」が分からなくなってUXが混乱します。そこで、ナビ展開中はページのスクロールを止めるのが王道です。

function lockScroll() {
  document.body.style.overflow = "hidden";
}

function unlockScroll() {
  document.body.style.overflow = "";
}

やっていることはシンプルで、document.body.style.overflow"hidden" を入れて背面のスクロールを止め、閉じるときは ""(空文字)を代入してインラインスタイルを除去しています。"" を入れることで、CSSファイル側のスタイルにきれいに戻る仕組みです。

なお、iOS Safariでは body { overflow: hidden } だけではスクロールが止まりきらないケースもあります。本スニペットではシンプルさを優先して overflow: hidden を採用していますが、より厳密に止めたい場合は position: fixedbody に当てるテクニックもあります。実案件で必要になったら検討してみてください。

フォーカストラップ — ロゴも含めてループさせる設計

ここが本記事の目玉です。フォーカストラップとは、Tabキーでのフォーカス移動を特定の範囲内に閉じ込める仕組みのこと。スクリーンリーダーやキーボードのみで操作するユーザーが、ナビ外にフォーカスが飛んでしまって混乱しないようにするための仕掛けです。

ここで一度、本スニペットの状況を整理しましょう。ナビが展開している間、画面上で操作可能な要素は3グループあります。

  1. ヘッダー左のロゴ(.c-header__logo
  2. ヘッダー右のハンバーガーボタン(.c-hamburger
  3. スライドインナビ内のリンク(5つ)

このうち1と2は、ヘッダーが --z-floating でナビより前面に出ているせいで、ナビ展開中もキーボードからフォーカスできる状態にあります。もしフォーカストラップの範囲を「ナビ内のリンクだけ」にしてしまうと、こんな問題が起きます。

キーボードユーザーがTabを押してロゴにフォーカスする → さらにTabを押す → ナビ内に戻れない

これは本スニペット特有の落とし穴です。ヘッダーをナビより前面に出した瞬間に、フォーカストラップの「範囲設計」が変わってしまいます。

そこで本スニペットでは、フォーカストラップの範囲に headerLogo + hamburger + ナビ内リンク の3セットをすべて含めています。

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

  // ヘッダーロゴ + ハンバーガーボタン + スライドインナビ内のフォーカス可能要素を結合
  const navFocusable = Array.from(slideinNav.querySelectorAll(FOCUSABLE_SELECTOR));
  const focusableElements = headerLogo
    ? [headerLogo, hamburger, ...navFocusable]
    : [hamburger, ...navFocusable];
  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();
    }
  }
}

focusableElements を組み立てている3行が、本スニペットのアクセシビリティを支えている要です。「ロゴ → ハンバーガー → ナビ内リンク1 → リンク2 → … → リンク5 → ロゴに戻る」というループが完成します。

ロゴのDOMが見つからない場合(古いHTMLでロゴクラスが違うなど)に備えて、headerLogo の存在チェックも入れています。「headerLogo があれば先頭に追加、なければハンバーガーから始める」という三項演算子の使い方は、安全側に倒した実装パターンとして覚えておいて損はありません。

ヘッダーを前面に浮かべる設計を採用したからこそ、フォーカストラップの範囲もそれに合わせて広げる必要がある——この設計と実装の連動がアクセシビリティを担保する考え方です。

ナビ外クリック・Escapeキー・リサイズ対応

最後に、UXを支える残りの3つの処理をまとめて見ておきましょう。

// ナビ外クリックで閉じる
document.addEventListener("click", function (e) {
  const isOpen = slideinNav.classList.contains("is-open");
  if (!isOpen) return;

  const isOutside =
    !hamburger.contains(e.target) && !slideinNav.contains(e.target);
  if (isOutside) {
    closeMenu();
  }
});

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

// 画面幅が768px以上になったらナビを閉じる(縦横回転・リサイズ対応)
const mediaQuery = window.matchMedia("(min-width: 768px)");
mediaQuery.addEventListener("change", function (e) {
  if (e.matches) {
    if (slideinNav.classList.contains("is-open")) {
      closeMenu();
    }
    slideinNav.removeAttribute("aria-hidden");
  } else {
    slideinNav.setAttribute("aria-hidden", "true");
  }
});

ナビ外クリックは、document 全体のクリックを監視して、クリック対象がハンバーガーにもナビ内にも含まれていなければ閉じる、というロジックです。element.contains(e.target) は「クリックされた要素がこの要素の中に入っているか?」を判定する便利なメソッドなので覚えておいて損はありません。

Escapeキーは、ナビが開いている状態でEscが押されたら閉じて、フォーカスをハンバーガーボタンに戻します。hamburger.focus() でフォーカスを戻すのは、キーボードだけで操作しているユーザーが「閉じた後どこにいるのか」を見失わないための気遣いです。

ここで keydown リスナーが1つに統合されている点にも注目してください。Escape処理とフォーカストラップ処理を1つのリスナーにまとめることで、リスナーが2つに分散して片方を消し忘れる、といった事故を防げます。

リサイズ対応には matchMedia("(min-width: 768px)") を使っています。スマホを横向きにしたり、ブラウザの幅を広げたりして768px以上になったら、ナビを自動で閉じて aria-hidden 属性を除去しています。PCでは常時表示されるので、aria-hidden="false" を残すよりも属性自体を消したほうが意味的に正しいからです。逆にSPサイズに戻ったら aria-hidden="true" を付け直しています。

CSSのメディアクエリ @media (min-width: 768px) とJavaScriptの matchMedia("(min-width: 768px)")同じ値で揃えている点も大事です。片方だけ変えるとブレークポイントの境界で表示と動作がズレる原因になります。カスタマイズするときは両方を必ずセットで変更してください。

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

ここからはコピペで使えるソースコードを全文掲載します。GitHubを使わずにそのまま貼り付けたい方は、以下からお持ち帰りください。

GitHubから取得したい方はこちら

🌐 デモを見る(GitHub Pages)

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

HTML(index.html)

<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-slidein-nav"
    >
      <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>

<!-- スライドインナビゲーション(モバイル: 右側から左方向にスライドして全画面表示) -->
<nav
  class="c-slidein-nav"
  id="c-slidein-nav"
  aria-label="スライドインナビゲーション"
  aria-hidden="true"
>
  <ul class="c-slidein-nav__list">
    <li class="c-slidein-nav__item"><a href="#" class="c-slidein-nav__link">TOP</a></li>
    <li class="c-slidein-nav__item"><a href="#" class="c-slidein-nav__link">ABOUT</a></li>
    <li class="c-slidein-nav__item"><a href="#" class="c-slidein-nav__link">WORKS</a></li>
    <li class="c-slidein-nav__item"><a href="#" class="c-slidein-nav__link">BLOG</a></li>
    <li class="c-slidein-nav__item"><a href="#" class="c-slidein-nav__link">CONTACT</a></li>
  </ul>
</nav>

CSS(assets/css/style.css)

/* ================================================
   カスタムプロパティ
   ================================================ */
:root {
  --header-height-sp: 60px;
  --header-height-pc: 72px;
  --slidein-transition: 0.3s ease;

  /* ===== z-index 階層管理 ===== */
  --z-drawer:    40; /* フルスクリーンナビ(右スライドイン) */
  --z-floating:  50; /* フローティング要素(ヘッダー借用用) */
}

/* ================================================
   c-header: ヘッダー全体
   ================================================ */
.c-header {
  position: sticky;
  top: 0;
  /* ナビ展開時もヘッダーを前面に保つため、通常の --z-header(20) ではなく --z-floating(50) を借用 */
  z-index: var(--z-floating);
  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-slidein-nav: スライドインナビゲーション
   画面右側外(translateX(100%))に配置し、
   is-open で translateX(0) にスライドして全画面を覆う
   ================================================ */
.c-slidein-nav {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh; /* 画面全体を覆う */
  z-index: var(--z-drawer); /* ドロワー / フルスクリーンナビ */
  background-color: #fff;

  /* ヘッダー分の余白を確保し、ナビリンクがヘッダーと重ならないようにする */
  padding-top: var(--header-height-sp);

  /* 初期状態: 画面右側外に配置 */
  transform: translateX(100%);
  transition: transform var(--slidein-transition);
}

/* is-open クラスで左にスライドして全画面表示 */
.c-slidein-nav.is-open {
  transform: translateX(0);
}

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

/* ================================================
   c-slidein-nav__list / item / link: メニューリスト
   ================================================ */
.c-slidein-nav__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* モバイル: 区切り線 */
.c-slidein-nav__item {
  border-bottom: 1px solid #f0f0f0;
}

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

.c-slidein-nav__link {
  display: block;
  padding: 16px 20px;
  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-slidein-nav__link:hover {
    color: #0066cc;
  }
}

JavaScript(assets/js/script.js)

/**
 * 003_hamburger-slidein-right
 * 右スライドイン型ハンバーガーメニュー(全画面)
 */

(function () {
  "use strict";

  // 要素取得
  const hamburger = document.querySelector(".c-hamburger");
  const slideinNav = document.querySelector(".c-slidein-nav");
  const headerLogo = document.querySelector(".c-header__logo");

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

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

  /**
   * スクロールロック
   */
  function lockScroll() {
    document.body.style.overflow = "hidden";
  }

  function unlockScroll() {
    document.body.style.overflow = "";
  }

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

    hamburger.setAttribute("aria-expanded", "true");
    hamburger.setAttribute("aria-label", "メニューを閉じる");
    slideinNav.setAttribute("aria-hidden", "false");

    lockScroll();

    const firstFocusable = slideinNav.querySelectorAll(FOCUSABLE_SELECTOR)[0];
    if (firstFocusable) {
      // 300ms = CSS カスタムプロパティ --slidein-transition(0.3s)に合わせること
      setTimeout(function () {
        firstFocusable.focus();
      }, 300);
    }
  }

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

    hamburger.setAttribute("aria-expanded", "false");
    hamburger.setAttribute("aria-label", "メニューを開く");
    slideinNav.setAttribute("aria-hidden", "true");

    unlockScroll();
  }

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

  /**
   * フォーカストラップ
   * ヘッダーロゴ+ハンバーガーボタン+スライドインナビ内のフォーカス可能要素の間でループ
   */
  function trapFocus(e) {
    if (e.key !== "Tab") return;
    if (!slideinNav.classList.contains("is-open")) return;

    const navFocusable = Array.from(slideinNav.querySelectorAll(FOCUSABLE_SELECTOR));
    const focusableElements = headerLogo
      ? [headerLogo, hamburger, ...navFocusable]
      : [hamburger, ...navFocusable];
    if (focusableElements.length === 0) return;

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

    if (e.shiftKey) {
      if (document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
    } else {
      if (document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
  }

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

  // ナビ外クリックで閉じる
  document.addEventListener("click", function (e) {
    const isOpen = slideinNav.classList.contains("is-open");
    if (!isOpen) return;

    const isOutside =
      !hamburger.contains(e.target) && !slideinNav.contains(e.target);
    if (isOutside) {
      closeMenu();
    }
  });

  // キーボード操作(Escape / Tab)を1つのリスナーで統合管理
  document.addEventListener("keydown", function (e) {
    if (e.key === "Escape" && slideinNav.classList.contains("is-open")) {
      closeMenu();
      hamburger.focus();
      return;
    }
    if (e.key === "Tab") {
      trapFocus(e);
    }
  });

  // 画面幅が768px以上になったらナビを閉じる(縦横回転・リサイズ対応)
  const mediaQuery = window.matchMedia("(min-width: 768px)");
  mediaQuery.addEventListener("change", function (e) {
    if (e.matches) {
      if (slideinNav.classList.contains("is-open")) {
        closeMenu();
      }
      slideinNav.removeAttribute("aria-hidden");
    } else {
      slideinNav.setAttribute("aria-hidden", "true");
    }
  });
})();

カスタマイズポイント

コピペした後、自分のサイトに合わせて調整したい箇所をまとめます。

  • ブレークポイント(768px): CSSの @media (min-width: 768px) とJSの matchMedia("(min-width: 768px)")同じ値で揃えて変更する
  • スライドアニメーション速度: --slidein-transition: 0.3s ease を変更する。JSの setTimeout(300) も同じミリ秒数に必ず合わせて変更する
  • ナビ背景色: .c-slidein-navbackground-color を変更する(本スニペットは白背景)
  • ヘッダーの高さ: --header-height-sp / --header-height-pc を変更する。ナビの padding-top に自動で反映される
  • z-index階層: --z-drawer / --z-floating の値を変更する(他のフローティング要素と競合する場合に調整)
  • スライド方向(左から右に変えたい場合): .c-slidein-navleft: 0right: 0 に、transform: translateX(100%)translateX(-100%) に変更する

よくある質問

なぜ position: fixed ではなく position: sticky をヘッダーに使っているの?

sticky は「親要素の中でスクロールに追従する」性質があり、コンテンツの流れに自然に組み込めるためです。fixed だとヘッダーが完全に文書の流れから外れてしまうため、ヘッダー直下のコンテンツが裏に隠れる対策(padding-top の付与など)を別途行う必要が出ます。本スニペットでは sticky で十分に役割を果たせるので、シンプルさを優先しています。

--z-floating を借用しているのはなぜ?ヘッダー専用の --z-header を作るほうが分かりやすそうだけど…

レイヤーの「役割」と「使う場所」を切り分けたかったからです。本来 --z-header のようなヘッダー専用レイヤーは下位(20前後)に置くのが一般的で、その想定を崩すとプロジェクト全体のz-index設計に矛盾が出ます。代わりに「フローティング要素用の上位レイヤー」である --z-floating を借りることで、ヘッダー以外の用途(モーダル・通知バナー等)にもこのレイヤーを使い回せる柔軟性を残しています。

フォーカストラップにロゴを含めなかったらどうなるの?

キーボードユーザーがTabでロゴにフォーカスした後、ナビ内の要素に戻れなくなる可能性があります。Tabキーを押すと「ナビ外のどこか」にフォーカスが飛んでしまい、ユーザーが現在地を見失ってしまう状態です。本スニペットはヘッダーをナビより前面に出している都合上、ロゴが「ナビ展開中もフォーカスできる要素」になっているので、トラップ範囲に必ず含める必要があります。

iOS Safariでスクロールロックがうまく効かないことがあるって本当?

はい、body { overflow: hidden } だけだとiOS Safariの一部バージョンで背面がスクロールしてしまうケースが知られています。本スニペットではシンプルさを優先して overflow: hidden を採用していますが、より厳密に止めたい場合は bodyposition: fixedtop: -スクロール位置 を当てて、閉じるときに位置を復元する手法もあります。実案件で必要になったら検討してみてください。

ハンバーガーボタンの×変形で「7px」という値はどこから来ているの?

線の高さ(2px)と線同士のgap(5px)を足した「7px」です。中央の線の位置までスライドさせるために、上下の線をその距離分だけ動かしています。CSSのコメントにも /* 線の高さ(2px) + gap(5px) = 7px */ と書いてあるので、gap を変更するときはこの計算もセットで見直してください。

まとめ

この記事では、右から左に全画面でスライドインするハンバーガーメニューの実装を解説しました。

  • 右端から左方向への全画面スライドインは transform: translateX(100%→0) で実装する(left アニメーションより滑らか)
  • z-indexをカスタムプロパティで管理し、ヘッダーに --z-floating(50) を借用することで、ナビ(40)の上にヘッダーを常時浮かべる
  • padding-top: var(--header-height-sp) でナビ上部のリンクがヘッダーに隠れないよう余白を確保する
  • フォーカストラップにはロゴ+ハンバーガーボタン+ナビ内リンクの3セットを含め、キーボードユーザーがどの要素からでもナビ内にループできるようにする
  • スクロールロック・Escapeキー・リサイズ対応・aria属性で、実案件レベルのアクセシビリティを担保する

私自身、ナビが全画面を覆うレイアウトを初めて作ったとき、「閉じ方が分からない」と言われて焦った経験があります。ヘッダーが裏に隠れてしまって、ユーザーが×ボタンを見つけられなかったのです。そこから「ナビが全画面を覆うなら、閉じるボタンはどこからでも触れる位置にあることが何より大事」と学び、ヘッダーを常時前面に出す設計に切り替えました。z-indexをカスタムプロパティで管理するようになってからは、「どのレイヤーに何を置くか」が一目で分かるので、設計の見通しが一気に良くなります。

まずはGitHub Pagesのデモで動きを確認して、気に入ったらGitHubリポジトリからコードを持ち帰ってみてください。カスタムプロパティを変えるだけで自分のサイトの雰囲気に合わせられるので、ぜひ試してみてください。