「ハンバーガーボタンをタップしたら、ヘッダーの裏からナビゲーションがスーッと下に降りてきた」——そんな印象的な演出のサイトを見たことはありませんか? 上下方向にスライドして全画面を覆うナビゲーションは、ポートフォリオやLPで一気にデザインの引き出しを広げてくれる表現の一つです。

この記事では、ヘッダーを sticky で固定したまま、その背面からナビゲーションが下方向にスライドダウンしてくる全画面ハンバーガーメニューの作り方を解説します。ライブラリなしで、CSSとJavaScriptだけで実装できます。

実際の動作はこちら

🌐 デモを見る(GitHub Pages)

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

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

この記事で分かること

  • transform: translateY(-100%) → 0 の上下移動だけで作る全画面スライドダウンの仕組み
  • 「ヘッダー背面からナビが降りてくる」演出を作る2層のz-index設計
  • スクロールロックを「あえて入れない」シンプル設計の理由
  • ハンバーガーボタンをフォーカストラップに含めるキーボード操作対応

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

このスニペットで作れるのは、ヘッダーを画面上部に固定したまま、その背面からナビゲーションが下方向にスライドダウンしてくる全画面メニューです。スマホでハンバーガーボタンをタップすると、ナビが上から滑り降りてきて画面いっぱいを覆います。

ヘッダーは常に最前面に表示されたままなので、ロゴと×ボタンはメニュー開放中もそのまま操作できます。ヘッダーの「裏側」にナビの板を差し込んでおいて、クリックで下にスライドさせる——そんなイメージで動きます。

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

  • ヘッダー背面から下にスライドダウンする全画面ナビゲーション
  • ナビは画面全体(100vh)を覆う
  • ヘッダーは sticky で画面上部に固定され、常にメニューより前面に表示される
  • 3本線→×(バツ)の変形アニメーション
  • ナビ外クリックで閉じる
  • Escapeキーで閉じる(フォーカスをハンバーガーボタンに戻す)
  • フォーカストラップ(Tabキーのフォーカスがハンバーガーボタン+ナビ内でループ)
  • 768px以上でPCナビに切り替わる(リサイズ対応あり)
  • aria属性によるアクセシビリティ対応

このスニペットの一番の特徴は、「ヘッダーを固定したまま、その背面からナビが降りてくる」演出を、z-indexの上下関係だけで作っているところです。難しいJSは使いません。

実際の動きはGitHub Pagesのデモで確認できます。スマホサイズに近づけて触ってみると、このあとの解説が一気に分かりやすくなりますよ。

HTMLの構造を見てみよう

HTMLのポイントは、PC用のナビ(c-nav)とスライドダウン用のナビ(c-slidedown-nav)を別々の要素として用意している構造です。PC用ナビはヘッダーの中、スライドダウンナビはヘッダーのに置いています。

<!-- ヘッダー -->
<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-slidedown-nav"
    >
      <span class="c-hamburger__line"></span>
      <span class="c-hamburger__line"></span>
      <span class="c-hamburger__line"></span>
    </button>

    <!-- PCナビ(省略) -->
  </div>
</header>

<!-- スライドダウンナビゲーション(モバイル: ヘッダー背面から下にスライドして表示) -->
<nav
  class="c-slidedown-nav"
  id="c-slidedown-nav"
  aria-label="スライドダウンナビゲーション"
  aria-hidden="true"
>
  <ul class="c-slidedown-nav__list">
    <li class="c-slidedown-nav__item"><a href="#" class="c-slidedown-nav__link">TOP</a></li>
    <!-- 他のリンクは省略 -->
  </ul>
</nav>

クラス名のプレフィックスには、FLOCSS(フロックス)というCSS設計ルールを採用しています。c- はComponent(再利用できる部品)を意味します。ヘッダー、ハンバーガーボタン、スライドダウンナビ、それぞれが独立した部品として設計されています。FLOCSSの詳しい解説はFLOCSSとは?CSS設計ルールをわかりやすく解説をご覧ください。

ハンバーガーボタンには <div> ではなく <button> タグを使います。キーボード操作やスクリーンリーダーへの対応が自動的に行われるため、アクセシビリティ上のベストプラクティスかもしれません。

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

スライドダウンナビは <header> の中ではなく、外に配置しています。理由は2つあります。

1つ目は、ヘッダー内に入れてしまうと stickyoverflow の影響を受けて、画面全体を覆うのが難しくなるからです。<header> の外に position: fixed で配置すれば、画面全体を自由に覆えます。

2つ目は、「ヘッダーが最前面に見える状態を保ちたい」からです。ナビをヘッダーの外に置いておけば、両者を独立したレイヤーとして扱えます。あとはz-indexで「ヘッダーが上、ナビが下」と並べるだけで、”ヘッダー背面から降りてくる”見た目が作れます。詳しくは次のCSSセクションで説明します。

aria属性の役割

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

属性役割
aria-label="メニューを開く"ボタンの名前をスクリーンリーダーに伝える(開閉状態に合わせて「閉じる」に切り替え)
aria-expanded="false"メニューの開閉状態を示す(開いたとき "true" に変わる)
aria-controls="c-slidedown-nav"このボタンが操作する要素のIDを示す(id="c-slidedown-nav" と対応)
aria-hidden="true"(スライドダウンナビ側)非表示のとき「この要素は読まなくていい」と伝える

最初は難しく感じるかもしれませんが、このスニペットをコピペするだけでアクセシビリティ対応ができます。慣れてきたら「どうしてこう書くのか」を一つずつ確認してみてください。

CSSで”ヘッダー背面から降りる”スライドダウンを実装する

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

まず最初に、調整しやすくするためのカスタムプロパティを :root にまとめています。

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

  /* ===== z-index 階層管理 ===== */
  --z-slidedown: 15; /* スライドダウンナビ(ヘッダー背面から展開) */
  --z-header:    20; /* ヘッダー背景 */
}

ヘッダーの高さやアニメーションの速度、そしてz-indexの2層構造をすべて変数で管理しているので、後から「もう少しゆっくり動かしたい」「ヘッダーをもっと高くしたい」といった調整がしやすくなります。

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

z-indexの2層設計 — “ヘッダー背面”を作る考え方

ここがこの記事のいちばんの見どころです。

普通、ハンバーガーメニューのナビをヘッダーよりに重ねる発想になりがちです。でもこのスニペットでは、あえてナビをヘッダーより(背面)に置いています。

:root {
  --z-slidedown: 15; /* ナビは下 */
  --z-header:    20; /* ヘッダーが上 */
}

たったこれだけで、「ヘッダーから湧き出してくるようにナビが降りてくる」演出が作れます。ナビがヘッダーの裏に隠れた状態でスタンバイしていて、クリックで下にスライドするから、ヘッダーから染み出すように見えるわけです。

z-indexの値をカスタムプロパティで一元管理しているのもポイントです。数値が散らばると「どれが上でどれが下か」が分からなくなりがちですが、変数にまとめておけば後から見返しても役割が一目で分かります。「この記事の独自ポイントは、z-indexを上下逆転させる発想」と覚えておくと、別の演出を考えるときの引き出しが増えるかもしれません。

ヘッダーを sticky で固定する

ヘッダーは position: sticky で画面最上部に固定します。

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

sticky は親要素内でスクロールに追従し、上端(top: 0)に到達すると固定される性質を持ちます。fixed と違って通常のフローを崩さないので、ヘッダーが「もとからそこにあるけど、スクロールしても張り付いて見える」自然な動きになります。

ここで指定した z-index: var(--z-header)(= 20)によって、スライドダウンナビ(z-index: 15)より常に前面に表示される構造が成立します。

スライドダウンナビの初期位置 — transform: translateY(-100%) で画面外に退避

スライドダウンナビは position: fixed で画面全体を覆うサイズを確保し、初期状態では translateY(-100%)画面の上に隠しておきます。

.c-slidedown-nav {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh; /* 画面最下部まで覆う */
  z-index: var(--z-slidedown);
  background-color: #fff;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);

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

  /* 初期状態: ヘッダーの裏に完全に隠れる位置(上方向にずらす) */
  transform: translateY(-100%);
  transition: transform var(--slidedown-transition);
}

transform: translateY(-100%)-100% は、「その要素自身の高さの100%ぶん」上方向にずらすという意味です。height: 100vh を指定しているので、画面1つ分まるまる上にズレて、ナビが完全に画面外(しかもヘッダーの裏側)に退避します。

「ヘッダーの背面に隠れている」ように見えるのは、z-index(15)でヘッダー(20)より下にいるからです。topleft を無理に動かすのではなく、transform だけで位置をズラすのがポイントです。

padding-top: var(--header-height-sp) で、ナビの上端にヘッダー分の余白を確保しています。これがあることで、開いたときにメニューリンクの1つ目がヘッダーに重ならず、すぐ下から始まります。

is-open クラスで translateY(0) にスライドダウンさせる

JavaScriptから is-open クラスを付けたとき、translateY(0) で元の位置(top: 0 の地点)に戻します。

.c-slidedown-nav.is-open {
  transform: translateY(0);
}

たったこれだけです。transition: transform var(--slidedown-transition) を初期状態に書いておいたおかげで、-100% から 0 への移動が0.3秒かけてなめらかにアニメーションします。

「上下移動だけでスライドダウン表現が作れる」のがこのアプローチの手軽さです。topheight をアニメーションするよりも、transform のほうがGPUで処理されて滑らかに動きます。レイアウトの再計算(リフロー)が発生しないため、スマホでもカクつきにくい動きになります。

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

ハンバーガーボタンのアニメーションは、3本線それぞれに transform をかけて×形に組み替えるシンプルな作りです。

/* 1本目: 下に7px下がって45度回転 */
.c-hamburger.is-active .c-hamburger__line:nth-child(1) {
  transform: translateY(7px) rotate(45deg);
}

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

/* 3本目: 上に7px上がって -45度回転 */
.c-hamburger.is-active .c-hamburger__line:nth-child(3) {
  transform: translateY(-7px) rotate(-45deg);
}

translateY(7px) の数値には根拠があります。線の高さ(height: 2px)と、線間のギャップ(gap: 5px)を足すと 2 + 5 = 7px です。この距離だけ上下に動かすことで、1本目と3本目が中央の線の位置で交差して、きれいな×形になります。

真ん中の線は opacity: 0 で消えるので、3本のうち1本目と3本目だけが残って交差する仕組みです。数値の根拠が分かっていれば、線の太さやgapを変えたときに自分で調整できるようになります。

JavaScriptの実装を見てみよう

JavaScript全文はGitHubリポジトリに譲り、ここでは核となる処理に絞って解説します。

このスニペットのJSは、即時関数 (function () { ... })() で全体を囲んでいます。これは「即時実行関数式(IIFE)」と呼ばれる書き方で、関数の中で宣言した変数がグローバルスコープを汚染しないようにするためのお作法です。他のJSと組み合わせたときに名前がぶつかりにくくなります。

openMenu / closeMenu の役割

メニューの開閉はそれぞれ専用の関数に分けています。

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

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

  // スライドダウンナビ内の最初のリンクにフォーカス移動
  const firstFocusable = slidedownNav.querySelectorAll(FOCUSABLE_SELECTOR)[0];
  if (firstFocusable) {
    setTimeout(function () {
      firstFocusable.focus();
    }, 300);
  }
}

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

  // aria 属性更新
  hamburger.setAttribute("aria-expanded", "false");
  hamburger.setAttribute("aria-label", "メニューを開く");
  slidedownNav.setAttribute("aria-hidden", "true");
}

openMenu ではクラス付与とaria属性更新、そしてフォーカス移動を行います。closeMenu はその逆です。フォーカス移動を setTimeout で300ms遅らせているのは、CSSの --slidedown-transition: 0.3s と合わせるためで、トランジション完了後にフォーカスが当たるように調整しています。

openMenu の中に document.body.style.overflow = "hidden" のようなスクロールロックは書かれていません。これがこのスニペットの設計上の特徴で、次の項で詳しく説明します。

スクロールロックが”不要”な理由

このスニペットでは、あえてスクロールロックを入れていません。

理由はシンプルで、ヘッダーが常に最前面に残っているからです。スライドダウンナビが画面全体を覆っているように見えても、ヘッダー部分(z-index: 20)はその上にずっと表示されています。ユーザーは常に「自分が今どのページのどこにいるか」の手がかりとしてヘッダーを認識できます。

このため、メニューを開いたまま背景のコンテンツがスクロールしても、UXが大きく崩れません。「ヘッダーから一時的に降りてきたメニュー」という体験設計なので、ロックする必要がないわけです。

実装としては、コードが1行少なくなる分シンプルになります。「実装のシンプルさが、結果としてUXの良さにつながることもある」というのは、コーディングをしていて面白いところかもしれません。複雑な仕組みを足すより、設計で解決できないかを先に考えるクセをつけたいところです。

スクロールロックを入れたい場合

もし「メニュー開放中は背景を絶対に動かしたくない」という要件があるときは、openMenu の最後に document.body.style.overflow = "hidden";closeMenu の最後に document.body.style.overflow = ""; を追加すれば対応できます。

フォーカストラップ — ハンバーガーボタンもトラップ範囲に含める

フォーカストラップとは、Tabキーのフォーカスをメニュー内に閉じ込める仕組みです。メニューが開いているのに、Tabキーでメニューの外にフォーカスが移ってしまうと、キーボード操作のユーザーが迷子になります。

このスニペットのフォーカストラップで特徴的なのは、ハンバーガーボタンをトラップ範囲に含めている点です。

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

  // ハンバーガーボタン + スライドダウンナビ内のフォーカス可能要素を結合
  const navFocusable = Array.from(slidedownNav.querySelectorAll(FOCUSABLE_SELECTOR));
  const focusableElements = [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();
    }
  }
}

const focusableElements = [hamburger, ...navFocusable] の1行で、ハンバーガーボタンを配列の先頭に追加しています。

このスニペットには「ドロワーの中に閉じるボタン」がありません。ナビを閉じる役割は、ヘッダー上のハンバーガーボタン(×状態)が担っています。だからこそ、Tabキーのループに×状態のハンバーガーボタンも含めておく必要があるんです。これがないと、キーボードユーザーはメニューを閉じる手段を失ってしまいます。

順番としては「×ボタン → メニューリンク1 → メニューリンク2 → … → 最後のリンク → ×ボタンに戻る」というループ。Tabキーだけでメニューを開いて、リンクを選んで、閉じるところまで完結できる設計です。

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

最後に、補助的なキー操作とリサイズ対応について触れておきます。

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

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

// 画面幅が768px以上になったらメニューを閉じる
const mediaQuery = window.matchMedia("(min-width: 768px)");
mediaQuery.addEventListener("change", function (e) {
  if (e.matches) {
    if (slidedownNav.classList.contains("is-open")) {
      closeMenu();
    }
    slidedownNav.removeAttribute("aria-hidden");
  } else {
    slidedownNav.setAttribute("aria-hidden", "true");
  }
});

Escapeキーで閉じたあと hamburger.focus() でフォーカスをハンバーガーボタンに戻すのは、キーボード操作のユーザーがフォーカスを見失わないための小さな配慮です。

ナビ外クリックは hamburger.contains(e.target)slidedownNav.contains(e.target) の両方が false(=どちらの内側でもない)ときに閉じる判定です。クリックされた要素がナビの中なのか外なのかを正確に判定できます。

リサイズ対応は window.matchMedia("(min-width: 768px)") で検知します。PCサイズに広がったときはメニューを閉じて aria-hidden を除去します。PCでは横並びナビが常時表示される設計なので、「非表示状態」を示す aria-hidden は不要だからです。スマホサイズに戻ったときは aria-hidden="true" を再度付与します。

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

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

🌐 デモを見る(GitHub Pages)

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

GitHubから直接クローンしたい方はリポジトリを、コピペで十分な方は下の全文をそのまま使ってください。HTML・CSS・JSの3ファイル構成で、そのまま動作します。

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

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-slidein-top</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-slidedown-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-slidedown-nav"
      id="c-slidedown-nav"
      aria-label="スライドダウンナビゲーション"
      aria-hidden="true"
    >
      <ul class="c-slidedown-nav__list">
        <li class="c-slidedown-nav__item"><a href="#" class="c-slidedown-nav__link">TOP</a></li>
        <li class="c-slidedown-nav__item"><a href="#" class="c-slidedown-nav__link">ABOUT</a></li>
        <li class="c-slidedown-nav__item"><a href="#" class="c-slidedown-nav__link">WORKS</a></li>
        <li class="c-slidedown-nav__item"><a href="#" class="c-slidedown-nav__link">BLOG</a></li>
        <li class="c-slidedown-nav__item"><a href="#" class="c-slidedown-nav__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;
  --slidedown-transition: 0.3s ease;

  /* ===== z-index 階層管理 ===== */
  --z-slidedown: 15; /* スライドダウンナビ(ヘッダー背面から展開) */
  --z-header:    20; /* ヘッダー背景 */
}

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

/* ×(バツ)アニメーション */
.c-hamburger.is-active .c-hamburger__line:nth-child(1) {
  transform: translateY(7px) rotate(45deg);
}

.c-hamburger.is-active .c-hamburger__line:nth-child(2) {
  opacity: 0;
}

.c-hamburger.is-active .c-hamburger__line:nth-child(3) {
  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-slidedown-nav: スライドダウンナビゲーション
   ヘッダーの背面(z-index: 15 < header: 20)に配置し、
   transform: translateY で上下にスライドさせる
   ================================================ */
.c-slidedown-nav {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh; /* 画面最下部まで覆う */
  z-index: var(--z-slidedown); /* ヘッダー(20)より低い値でヘッダー背面に配置 */
  background-color: #fff;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);

  /* padding-top でヘッダー分の余白を確保し、ナビ自体はヘッダーの真下から始まる */
  padding-top: var(--header-height-sp);

  /* 初期状態: ヘッダーの裏に完全に隠れる位置(上方向にずらす) */
  transform: translateY(-100%);
  transition: transform var(--slidedown-transition);
}

/* is-open クラスで下にスライドして表示 */
.c-slidedown-nav.is-open {
  transform: translateY(0);
}

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

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

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

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

.c-slidedown-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-slidedown-nav__link:hover {
    color: #0066cc;
  }
}

JavaScript(assets/js/script.js)

/**
 * 002_hamburger-slidein-top
 * スライドダウン型ハンバーガーメニュー
 */
(function () {
  "use strict";

  // 要素取得
  const hamburger = document.querySelector(".c-hamburger");
  const slidedownNav = document.querySelector(".c-slidedown-nav");

  // 要素が存在しない場合は処理しない
  if (!hamburger || !slidedownNav) 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");
    slidedownNav.classList.add("is-open");

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

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

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

    // aria 属性更新
    hamburger.setAttribute("aria-expanded", "false");
    hamburger.setAttribute("aria-label", "メニューを開く");
    slidedownNav.setAttribute("aria-hidden", "true");
  }

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

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

    // ハンバーガーボタン + スライドダウンナビ内のフォーカス可能要素を結合
    const navFocusable = Array.from(slidedownNav.querySelectorAll(FOCUSABLE_SELECTOR));
    const focusableElements = [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();
      }
    }
  }

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

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

    // クリック対象がハンバーガーまたはスライドダウンナビの内側でなければ閉じる
    const isOutside =
      !hamburger.contains(e.target) && !slidedownNav.contains(e.target);
    if (isOutside) {
      closeMenu();
    }
  });

  // キーボード操作(Escape / Tab)を1つのリスナーで統合管理
  document.addEventListener("keydown", function (e) {
    // Escape: メニューを閉じてフォーカスを戻す
    if (e.key === "Escape" && slidedownNav.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) {
      // PCサイズ: ナビは常に表示されるため aria-hidden を除去
      if (slidedownNav.classList.contains("is-open")) {
        closeMenu();
      }
      slidedownNav.removeAttribute("aria-hidden");
    } else {
      // SPサイズに戻ったとき: ナビは非表示状態なので aria-hidden を付与
      slidedownNav.setAttribute("aria-hidden", "true");
    }
  });
})();

カスタマイズポイント

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

  • ブレークポイント(768px): CSSのメディアクエリとJSの matchMedia("(min-width: 768px)") を同じ値に揃えて変更してください。片方だけ変えると、リサイズ時の挙動がズレます。
  • ヘッダーの高さ: :root--header-height-sp / --header-height-pc を変更します。スライドダウンナビの padding-top も連動するため、リストとヘッダーが重なる心配はありません。
  • スライドアニメーション速度: :root--slidedown-transition を変更します(例: 0.3s ease0.5s ease)。JS側の setTimeout の数値(300)も合わせて変更すると、フォーカス移動のタイミングがズレません。
  • ヘッダー背景色・ボーダー・シャドウ: .c-headerbackground-color / border-bottom / box-shadow を変更します。
  • ナビ背景色・リンクの余白: .c-slidedown-navbackground-color.c-slidedown-nav__linkpadding を変更します。
  • z-indexの階層: --z-slidedown / --z-header の数値を変更できますが、両者の上下関係(ヘッダーが上)は必ず維持してください。逆転すると「ヘッダー背面から降りてくる」演出が壊れます。

よくある質問(FAQ)

なぜ top ではなく transform: translateY でアニメーションするのですか?

transform はGPUで処理されるため、top をアニメーションするより滑らかに動きます。top を変えるとブラウザがレイアウトを再計算(リフロー)するため、スマホなどの非力な端末でカクつきやすい傾向があります。同じ「位置を動かす」演出でも、transform を選ぶ理由はパフォーマンスにあります。

ヘッダーを sticky ではなく fixed にしてもいいですか?

動作としては可能ですが、fixed の場合は高さ分の余白を bodymain に手動で確保する必要があります。sticky なら通常のフローを崩さないため、後続のコンテンツがヘッダーに隠れる心配がありません。「ヘッダーが画面に張り付くだけ」の用途なら sticky のほうがシンプルかもしれません。

スクロールロックを入れないと、メニューを開いたまま背景がスクロールしてしまいませんか?

その通りで、背景はスクロールできる状態のままです。このスニペットでは「ヘッダーが常に最前面に残っているため、ユーザーが画面の現在位置を見失わない」ことを前提に、あえてロックを入れていません。要件として「絶対にスクロールさせたくない」場合は、openMenu の最後に document.body.style.overflow = "hidden";closeMenu の最後に document.body.style.overflow = ""; を追加することで対応できます。

PCナビ(c-nav)とスライドダウンナビ(c-slidedown-nav)でリンクが2回書かれているのが冗長に感じます。1つにまとめられませんか?

JavaScriptで生成すれば1つにまとめることもできますが、HTMLが2倍になる代わりに「JSが動かない環境でもメニューが見える」という安心感があります。SEO観点でも、HTMLにリンクが直接書かれているほうが検索エンジンに発見されやすい傾向があります。可読性とのトレードオフですので、プロジェクトの方針に合わせて判断するとよいかもしれません。

ハンバーガーボタンをフォーカストラップに含めないとどうなりますか?

キーボード操作のユーザーが「メニューを閉じる手段」を失います。このスニペットはドロワーの中に閉じるボタンを置いていないため、×状態のハンバーガーボタンが唯一の閉じる手段です。これがフォーカスループから外れると、Tabキーだけでメニューを操作するユーザーは詰んでしまいます。Escapeキーで閉じる動作も用意していますが、フォーカストラップとEscapeキーの両方を備えておくと、より親切な実装になります。

まとめ

この記事では、ヘッダー背面から下方向にスライドダウンしてくる全画面ハンバーガーメニューの実装を解説しました。

ポイントを振り返ります。

  • transform: translateY(-100%) → 0 の上下移動だけで、全画面スライドダウン表現が作れる
  • z-indexを「ヘッダー20 > スライドダウンナビ15」と設計することで、”ヘッダー背面から湧き出る”演出が生まれる
  • スクロールロックはあえて不要。ヘッダーが常に最前面に残るため、ユーザーの画面把握が失われない
  • フォーカストラップにハンバーガーボタンを含めることで、キーボードだけで開閉まで完結できる

z-indexは「数字が大きければ上」という単純なルールですが、あえてナビを下に置いて背面から動かすという発想を持つだけで、デザインの引き出しが一気に広がります。普段の実装で「とりあえずナビを上に重ねる」しか選択肢がなかった方は、ぜひこの「裏に置く」アプローチを試してみてください。

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