「ネイティブアプリのように、ボタンを押すとページ全体がスーッと横にスライドするメニュー、Webでも作れないかな?」——スマホアプリでよく見る、あの動きです。
この記事では、ボタンを押すとページコンテンツが左に押し出され、右から280px幅のナビゲーションパネルが押し込むように現れる「push型ハンバーガーメニュー」の作り方を解説します。「push型」と呼んでいますが(カタカナで「プッシュメニュー」と呼ばれることもあります)、要はページコンテンツ自体が左にスライドするタイプのハンバーガーメニューのことです。ライブラリは使いません。CSSとJavaScriptだけで実装できます。
実際の動作はこちら
コード全文はGitHubリポジトリで公開しているので、本記事では動作の核になる部分だけを抜粋して解説します。全部のコードを頭に入れる必要はありません。なぜこう書くのかを理解して、安心してコピペできるようになることがゴールです。
この記事で分かること
- push型ハンバーガーメニュー(コンテンツが左にスライドするタイプ)のHTML・CSS・JS実装の全体像
- ヘッダーをラッパーの外に独立配置する構造的工夫と、その理由(
position: fixedの祖先にtransformがあると挙動が崩れるCSSの落とし穴) - クリック閉じる用の「カバー要素」を別DOMで用意する設計と、ラッパー全体にクリックを張る実装との違い
- スクロールロック・フォーカストラップ・aria属性まで含めた、実案件で求められるアクセシビリティ対応の入れ方
- そのままコピペで使えるHTML/CSS/JSの全文
このスニペットで作れるもの
スマホ幅で3本線のハンバーガーボタンをタップすると、右から280px幅のナビゲーションパネルがスライドインしてきます。同時に、ページコンテンツ全体が左に280pxだけ押し出されます。ナビが「コンテンツの上に重なる」のではなく、コンテンツのほうが「左に逃げる」イメージです。
このスニペットの最大の特徴は、ヘッダーだけはpush中も動かず、画面上部に固定されたままであること。動くのはあくまで「ヘッダーより下のコンテンツ部分」です。768px以上ではハンバーガーボタンが消え、通常の横並びナビゲーションに切り替わります。
動作の特徴をまとめます。
- 右からスライドインする280px幅のナビゲーションパネル
- ページコンテンツ(main)が左に同量だけ押し出される
- ヘッダーはpush中も動かず、画面上部に固定表示されたまま
- オーバーレイ(背景の暗幕)はなし
- 押し出されたコンテンツ部分をクリックするとメニューが閉じる
- Escapeキーでメニューを閉じられる
- メニュー開放中はページがスクロールしない(スクロールロック)
- Tabキーのフォーカスがナビ内でループする(フォーカストラップ)
- 768px以上でPCナビに切り替わり、push動作は無効化
- aria属性によるアクセシビリティ対応
実際の動きはGitHub Pagesのデモで触れます。スマホサイズに画面を縮めて、ボタンタップ→コンテンツが左にスライド→押し出された部分をクリックで閉じる、という一連の流れを確認してから読み進めると理解がスムーズです。
「ナビが上に重なる」のではなく「コンテンツ自体が左に逃げる」——この一点に押さえると、これから出てくる構造の意味がスッと入ってきます。
HTMLの構造を見てみよう
このスニペットのHTMLは、push型を成立させるために配置の工夫が入っています。コードの肝は「どの要素を、どの要素の中に入れるか」という構造の話なので、まずは骨組みだけを抜粋して見てみましょう(詳細なリンク項目を含む完全版はGitHubリポジトリで公開しています)。
<!-- ヘッダー(c-push-wrapper の外に配置し、push 時も動かない固定ヘッダーとして独立させる) -->
<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-push-nav"
>
<span class="c-hamburger__line"></span>
<span class="c-hamburger__line"></span>
<span class="c-hamburger__line"></span>
</button>
<nav class="c-nav" aria-label="グローバルナビゲーション">
<!-- PC用横並びナビ(リンクは省略) -->
</nav>
</div>
</header>
<!-- ページコンテンツラッパー(push時に左にスライドする要素。main のみ内包) -->
<div class="c-push-wrapper" id="c-push-wrapper">
<!-- カバー要素(is-pushed 時のみ前面に出てクリックで閉じる用) -->
<div class="c-push-cover" aria-hidden="true"></div>
<main class="l-container">
<!-- ページコンテンツ -->
</main>
</div>
<!-- Push Nav(モバイル: 右からスライドインするナビ) -->
<nav
class="c-push-nav"
id="c-push-nav"
aria-label="プッシュナビゲーション"
aria-hidden="true"
>
<!-- ナビリンク(省略) -->
</nav>ファイル名やクラス名に出てくる c- は、CSSの設計手法「FLOCSS」のComponent(再利用できる部品)を表すプレフィックスです。FLOCSSについてはFLOCSSとは?CSS設計ルールをわかりやすく解説で詳しく解説しています。
注目してほしいのは、3つの要素の配置関係です。
c-headerはc-push-wrapperの外にあるc-push-wrapperの中にはc-push-coverとmainだけが入っているc-push-navもc-push-wrapperの外にある
この3点の配置が、「push時もヘッダーが動かない」「ナビの幅と押し出し量が一致する」というpush型の挙動を成立させる土台になっています。次のセクションから、それぞれ「なぜそうしているのか」を順番に見ていきます。
なぜヘッダーを c-push-wrapper の外に置くのか
これがこの記事のいちばん大事なポイントです。
push型の設計意図は「コンテンツだけを左に押し出す。ヘッダーは動かさない」というものです。素直に考えると、ヘッダーもラッパーの中に入れて「全部まとめて左にスライドさせて、ヘッダーだけ position: fixed で固定すればいいのでは?」と思うかもしれません。実は、ここにCSSの落とし穴があります。
c-push-wrapper には、push中に transform: translateX(-280px) がかかります。もしヘッダーがこのラッパーの中にいると、transform の影響でヘッダーも一緒に左へズレてしまいます。「じゃあヘッダーに position: fixed をかければビューポートに固定できるはず」と思うところですが、ここで fixed が効かなくなります。
CSSの仕様で、祖先要素に transform が指定されていると、子孫の position: fixed の基準が「ビューポート」ではなく「transform を持つ祖先」になるというルールがあるからです。結果、fixed を指定したヘッダーは「ビューポートに固定」されず、c-push-wrapper を基準にして動いてしまいます。push時にラッパーが左に280px動けば、ヘッダーも一緒に左に280px動くわけです。これでは固定ヘッダーになりません。
この問題を避けるために、ヘッダーを c-push-wrapper の外側(body の直下)に独立して配置しています。こうすれば、c-push-wrapper の transform はヘッダーに影響しません。ヘッダーは position: fixed できちんとビューポートに固定され、ラッパーだけが左にスライドする、という設計が成立します。
「position: fixed の祖先に transform があると、fixed の基準が変わる」というCSSの仕様は、ふだんあまり意識しないところですが、push型のように transform を多用する実装では避けて通れない論点です。一度知っておくと、似た構造の不具合に当たったときに「あ、これか」と気づけるようになります。
カバー要素(c-push-cover)を別に用意する理由
次に注目したいのが、c-push-wrapper の中にある c-push-cover という空の div です。これは「メニューが開いているとき、押し出されたコンテンツ部分をクリックすると閉じる」を実現するための要素です。
素朴に考えると「c-push-wrapper 全体にクリックリスナーを張れば、コンテンツのどこをクリックしても閉じられるのでは?」と思いますよね。実際それでも動きはしますが、ラッパー内のリンクやボタンとクリックが干渉するという問題が起きます。たとえば、押し出された状態で「閉じる目的」でクリックしたつもりが、たまたまその下にあったリンクをクリックしてしまい、ページ遷移してしまう、といった誤動作です。
そこで、クリックを受け取る専用の透明レイヤーとして c-push-cover を用意します。通常時は display: none で完全に消しておき、is-pushed のときだけ display: block に切り替えてラッパー全面を覆います。覆っている間はカバーがクリックを受け取るので、その下にあるリンクやボタンには触れません。
「クリック領域を設計として分離する」という小さな工夫ですが、実務ではこういう細部が「触ってみたら気持ちいい」UIの差になります。
aria属性の役割
このスニペットで使っているaria属性をまとめます。aria属性は、スクリーンリーダーなどの支援技術に対して「この要素が今どんな状態か」を伝えるための仕組みです。
| 属性 | 役割 |
|---|---|
aria-label="メニューを開く"(ハンバーガー) | ボタンの名前を支援技術に伝える。開いたとき "メニューを閉じる" に動的変更 |
aria-expanded="false"(ハンバーガー) | メニューが閉じていることを示す。開いたとき "true" に変更 |
aria-controls="c-push-nav"(ハンバーガー) | このボタンが操作する要素のIDを示す |
aria-hidden="true"(プッシュナビ) | 初期状態では画面外に隠れているため、支援技術から見えないようにする。開いたとき "false" に変更 |
aria-hidden="true"(カバー要素) | 装飾目的の透明レイヤーなので、支援技術には常に存在しないものとして扱う |
「コピペで使えるけれど、aria属性は削らないこと」だけ覚えておいてください。見た目に出ない部分ですが、キーボード操作のユーザーやスクリーンリーダー利用者にとっては「メニューが今どうなっているか」を知る唯一の手がかりになります。
CSSでpush動作を実装する
CSS全文は長いのでGitHubリポジトリに譲り、ここではpush型の核になる部分だけを抜粋します。
冒頭で :root にカスタムプロパティを定義しています。
:root {
--header-height-sp: 60px;
--header-height-pc: 72px;
--push-nav-width: 280px;
--push-transition: 0.3s ease;
/* z-index 階層管理 */
--z-header: 20; /* ヘッダー背景 */
--z-drawer: 40; /* プッシュナビ */
}--push-nav-width: 280px はナビの幅であると同時にコンテンツの押し出し量でもあります。1つのカスタムプロパティを2か所で参照することで、「ナビ幅と押し出し量を必ず一致させる」設計を保証しています。後から「やっぱりナビは320pxにしたい」と思ったときも、このプロパティを書き換えるだけで両方が連動して変わります。
なお、CSS全文を覗くと margin-block や padding-inline といった見慣れない書き方が出てきます。これは「論理プロパティ」と呼ばれる比較的新しいCSSの書き方で、横書きの日本語サイトであれば従来の margin-top / margin-bottom / padding-left / padding-right の一括指定と同じ動きをします。「最近のコードでよく見る書き方」として読み流して問題ありません。
z-indexは2層構造でシンプル
このスニペットには「背景を暗くするオーバーレイ」がありません。そのぶん、z-indexの階層もシンプルに2層で済みます。
| 要素 | カスタムプロパティ | 値 |
|---|---|---|
ヘッダー(c-header) | --z-header | 20 |
プッシュナビ(c-push-nav) | --z-drawer | 40 |
オーバーレイ層を挟まないので、ヘッダーよりナビが上、それだけです。:root でカスタムプロパティとして一元管理しているので、プロジェクト全体のz-index設計と整合させたいときも、ここを書き換えるだけで済みます。
c-push-nav — 右からスライドインするナビパネル
プッシュナビは画面の右端に固定配置されていて、初期状態では画面の外(右側)に待機しています。
.c-push-nav {
position: fixed;
top: 0;
right: 0;
z-index: var(--z-drawer);
width: var(--push-nav-width);
height: 100%;
background-color: #2c2c2c;
transform: translateX(100%); /* 初期: 画面右外に待機 */
transition: transform var(--push-transition);
/* パネル境界を視覚的に強調する影 */
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.24);
/* ナビ内スクロール対応 */
overflow-y: auto;
}
.c-push-nav.is-open {
transform: translateX(0); /* is-open で画面内に */
}transform: translateX(100%) は「自分自身の幅の分だけ右にずらす」という意味なので、ナビは画面の右外にぴったり待機しています。is-open が付くと translateX(0) になり、transition で右からスライドインしてくる、という流れです。
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.24) は、ナビの左側に落ちる影です。オーバーレイがない設計なので、何もしないとナビとコンテンツの境界が曖昧になります。影でナビの外形を視覚的に強調することで、「ここからがナビパネル」と分かりやすくしています。オーバーレイなしのpush型ならではの、地味だけど効く工夫です。
overflow-y: auto を入れているのは、メニュー項目が増えて画面の高さを超えた場合にナビ内だけスクロールできるようにするためです。
c-push-wrapper — ページコンテンツを左に押し出す
push型の動きの核になるのが、この c-push-wrapper です。ここに is-pushed クラスが付くと、コンテンツ全体が左に押し出されます。
.c-push-wrapper {
position: relative;
min-height: 100vh;
padding-top: var(--header-height-sp); /* 固定ヘッダーの高さ分の余白 */
transition: transform var(--push-transition);
}
@media (min-width: 768px) {
.c-push-wrapper {
padding-top: var(--header-height-pc);
}
}
/* is-pushed クラスでコンテンツを左に押し出す */
.c-push-wrapper.is-pushed {
transform: translateX(calc(-1 * var(--push-nav-width)));
}
@media (min-width: 768px) {
/* PCではpush動作を無効化 */
.c-push-wrapper {
transform: none !important;
}
}ポイントは2つです。
1つめは padding-top: var(--header-height-sp) です。ヘッダーは c-push-wrapper の外側に独立して固定配置されているので、何もしないとラッパーの中身がヘッダーに重なってしまいます。ラッパーの上に「ヘッダーの高さぶんの余白」を作って、その重なりを避けています。
2つめは transform: translateX(calc(-1 * var(--push-nav-width))) です。ナビ幅と同じ値だけマイナス方向(左)に動かす、という指定です。--push-nav-width を一箇所で参照しているので、ナビ幅を変えれば押し出し量も自動で連動します。「ナビ幅と押し出し量を一致させる」という設計意図が、コードの上でも明示されている形です。
PC(768px以上)では transform: none !important; でpush動作そのものを無効化しています。!important を付けているのは、JSが付与する is-pushed の transform より確実に優先させるためです。PCではどんな状況でもラッパーが動かない、と保証する保険のようなものです。
c-push-cover — クリックで閉じる透明カバー
クリック分離のために用意したカバー要素のCSSです。
.c-push-cover {
/* 通常時は完全に無効化 */
display: none;
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
/* is-pushed 時のみ前面に出して有効化 */
.c-push-wrapper.is-pushed .c-push-cover {
display: block;
pointer-events: auto;
}
@media (min-width: 768px) {
.c-push-cover {
display: none !important;
}
}通常時は display: none と pointer-events: none の二段構えで無効化しています。display: none だけでも見た目は消えますが、念のため pointer-events も併記して「絶対にクリックを受け取らない」を明示しています。
.c-push-wrapper.is-pushed .c-push-cover のセレクタで、ラッパーに is-pushed が付いたときだけカバーを display: block に切り替えます。pointer-events: auto も同時に有効化されるので、このタイミングで初めてクリックを受け取り始めます。「使うときだけ有効、使わないときは完全に消す」というシンプルな設計です。
inset: 0 は top: 0; right: 0; bottom: 0; left: 0 の一括指定で、親要素(c-push-wrapper)の全面を覆う配置になります。
ハンバーガーボタンの×変形アニメーション
ハンバーガーボタンの×変形は、3本線それぞれに transform をかけて組み替えるだけのシンプルな仕組みです。
/* 1本目: 右上がりの斜線 */
.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本目: 右下がりの斜線 */
.c-hamburger.is-active .c-hamburger__line:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}translateY(7px) の 7px は、線の高さ(2px)と線間の gap(5px)を足した値です。この距離だけ動かすと、1本目と3本目が中央の位置で重なり、回転と合わせて×の形になります。真ん中の線は opacity: 0 で消すだけ、という割り切った作りです。
JavaScriptの実装を見てみよう
JavaScript全文もGitHubリポジトリに譲り、push型の核に絞って解説します。全体は即時関数 (function () { ... })(); で囲まれていて、変数や関数がグローバルに漏れない作りになっています。WordPressや他のスクリプトと共存させることを考えると、こういう「自分の世界で完結する」書き方が安心です。
openMenu / closeMenu の役割
メニューの開閉処理は openMenu と closeMenu の2つの関数にまとめています。
function openMenu() {
hamburger.classList.add("is-active");
pushNav.classList.add("is-open");
pushWrapper.classList.add("is-pushed");
// aria 属性更新
hamburger.setAttribute("aria-expanded", "true");
hamburger.setAttribute("aria-label", "メニューを閉じる");
pushNav.setAttribute("aria-hidden", "false");
// スクロールロック
document.body.style.overflow = "hidden";
// ナビ内の最初のフォーカス可能要素にフォーカス移動(トランジション完了後)
const firstFocusable = pushNav.querySelectorAll(FOCUSABLE_SELECTOR)[0];
if (firstFocusable) {
setTimeout(function () {
firstFocusable.focus();
}, 300); // CSS の --push-transition (0.3s) と合わせる
}
}
function closeMenu() {
hamburger.classList.remove("is-active");
pushNav.classList.remove("is-open");
pushWrapper.classList.remove("is-pushed");
hamburger.setAttribute("aria-expanded", "false");
hamburger.setAttribute("aria-label", "メニューを開く");
pushNav.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
}ポイントは、3つの要素に3つのクラスを同時に付ける/外すことです。
c-hamburgerにis-active(×変形)c-push-navにis-open(スライドイン)c-push-wrapperにis-pushed(左に押し出し)
この3つが同じタイミングで切り替わるからこそ、ボタン・ナビ・コンテンツが一体感のある動きになります。aria属性の更新も忘れずにセットで行うことで、見た目だけでなくスクリーンリーダーにも「今メニューが開いている/閉じている」が正確に伝わります。
setTimeout(..., 300) でフォーカス移動を300ミリ秒遅らせているのは、CSSの --push-transition: 0.3s が終わってからフォーカスを動かすためです。トランジション中に focus() を呼ぶと、ブラウザがフォーカス対象の位置までスクロールしようとしてアニメーションがカクついたり、意図しないジャンプが起きたりすることがあります。CSSとJSの数値を揃えるという地味な配慮ですが、体感の品質に効くポイントです。--push-transition を変更するときは、この 300 も同じ値に合わせるのを忘れないでください。
カバー要素のクリックで閉じる
push型ならではの実装が、カバー要素にクリックリスナーを張る部分です。
// 押し出されたコンテンツ部分(カバー要素)をクリックで閉じる
pushCover.addEventListener("click", function () {
if (!pushNav.classList.contains("is-open")) return;
closeMenu();
hamburger.focus();
});リスナーを張る対象は c-push-wrapper 全体ではなく、専用の c-push-cover です。HTMLとCSSのセクションでも触れたとおり、ラッパー全体にクリックを張ると内部のリンクやボタンと干渉するので、それを避けるための分離です。CSSで「カバーは is-pushed のときだけ前面に出る」と制御しているので、JSは「カバーがクリックされたら閉じる」だけを書けばよく、責務がスッキリします。
閉じた後の hamburger.focus() は、フォーカスをハンバーガーボタンに戻すための呼び出しです。キーボード操作のユーザーにとって、「今どこにフォーカスがあるか」が分からなくなるのはとてもストレスになります。「押した場所に戻る」という自然な流れをコードで担保しているわけです。
スクロールロックの仕組み
スクロールロックは document.body.style.overflow = "hidden" の一行で実現しています。
overflow: hidden を body に当てると、はみ出した部分が隠れる結果としてページ全体のスクロールが止まります。閉じるときは空文字 "" を代入してインラインスタイルを除去することで、デフォルトの状態に戻します。
push型では、ヘッダーが c-push-wrapper の外にあり、かつ c-push-wrapper には transform がかかっています。この状態で背景がスクロールできてしまうと、「ヘッダーは固定」「コンテンツは横に押し出されている」「背景は縦にもスクロール」という三重の動きになって、ユーザーの体感が混乱します。スクロールロックを入れることで、メニューが開いているあいだは余計な動きを止め、「今はメニューに集中する場面」だと示せます。
コラム: iOSではスクロールロックが効きにくい
実は
document.body.style.overflow = "hidden"は iOS Safari では完全にスクロールを止められないケースがあります。より堅牢にしたい場合は、bodyにposition: fixed; top: -{現在のscrollY}pxを当てて閉じるときに元の位置に戻す方式や、overscroll-behavior: containの併用を検討してみてください。本記事ではまず仕組みを理解することを優先し、シンプルな実装を採用しています。
フォーカストラップ
フォーカストラップは、Tabキーで移動するフォーカス(点線の枠)をナビの中だけに閉じ込める仕組みです。
function trapFocus(e) {
if (e.key !== "Tab") return;
if (!pushNav.classList.contains("is-open")) return;
const focusableElements = Array.from(pushNav.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();
}
}
}ナビの中のフォーカス可能な要素(リンク・ボタンなど)を配列で取り出し、最後の要素で Tab を押したら最初に戻す/最初の要素で Shift + Tab を押したら最後に飛ばす、という単純なループ構造です。これがないと、Tabキーを押し続けたときに背景のコンテンツへフォーカスが逃げてしまい、スクリーンリーダー利用者やキーボード操作のユーザーが混乱します。
push型では「ハンバーガーボタンはトラップ範囲に含めない」設計にしています。閉じる操作は「カバー要素のクリック」と「Escapeキー」で行うので、ボタンをループに含める必要がないからです。閉じる操作のための導線が複数あることで、ナビ内のリンク群だけにフォーカスを集中させられます。
Escapeキー・リサイズ対応
Escapeキーとリサイズ対応も、keydown と matchMedia のリスナーで実装しています。
// キーボード操作(Escape / Tab)を1つのリスナーで統合管理
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && pushNav.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 (pushNav.classList.contains("is-open")) {
closeMenu();
}
pushNav.removeAttribute("aria-hidden");
} else {
pushNav.setAttribute("aria-hidden", "true");
}
});EscapeとTabの処理を1つの keydown リスナーにまとめているのは、リスナーの数を減らすためです。複数のキーを別々のリスナーで処理するより、1つにまとめて中で e.key で分岐するほうが、コードが追いやすくなります。Escapeで閉じたあとに hamburger.focus() を呼ぶのは、カバークリックのときと同じく「押した場所にフォーカスを戻す」配慮です。
matchMedia("(min-width: 768px)") はメディアクエリと同じ条件をJSから監視する仕組みです。スマホ表示でメニューを開いたまま画面を回転させたり、ブラウザ幅を広げたりしてPCサイズに切り替わったとき、メニューが開きっぱなしにならないようにしています。あわせて aria-hidden 属性も管理していて、PCサイズのときは .c-push-nav が display: none で支援技術からも隠れるため aria-hidden を冗長指定にしないよう外し、SPに戻ったら再付与する、という細かい切り替えも入っています。
コピペで使えるソースコード全文
HTML・CSS・JSの全文は、GitHubリポジトリで公開しています。そのままコピペして使える状態で置いていますので、手元のプロジェクトに取り込んでお使いください。
- ソースコード全文(GitHub): https://github.com/kkurodalog/snippets-free/tree/master/components/navigation/005_hamburger-push
- 動作デモ(GitHub Pages): https://kkurodalog.github.io/snippets-free/components/navigation/005_hamburger-push/
コピペ用: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>005_hamburger-push</title>
<link rel="stylesheet" href="./assets/css/style.css" />
</head>
<body>
<!-- ヘッダー(c-push-wrapper の外に配置し、push 時も動かない固定ヘッダーとして独立させる) -->
<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-push-nav"
>
<span class="c-hamburger__line"></span>
<span class="c-hamburger__line"></span>
<span class="c-hamburger__line"></span>
</button>
<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>
<!-- ページコンテンツラッパー(push時に左にスライドする要素。main のみ内包する) -->
<div class="c-push-wrapper" id="c-push-wrapper">
<!-- カバー要素(is-pushed 時のみ前面に出てクリックで閉じる用) -->
<div class="c-push-cover" aria-hidden="true"></div>
<main class="l-container">
<section class="l-inner">
<h1 class="p-snippet-title">005_hamburger-push</h1>
<div class="p-snippet-content">
<p>ハンバーガーボタンをクリックすると右からナビゲーションパネルが出現し、ページコンテンツ全体が左に押し出されます。<br>押し出されたコンテンツ部分をクリックするとメニューが閉じます。<br>768px以上でハンバーガー非表示・通常ナビ表示に切り替わります。</p>
</div>
</section>
</main>
</div>
<!-- Push Nav(モバイル: 右からスライドインするナビゲーション) -->
<nav
class="c-push-nav"
id="c-push-nav"
aria-label="プッシュナビゲーション"
aria-hidden="true"
>
<ul class="c-push-nav__list">
<li class="c-push-nav__item"><a href="#" class="c-push-nav__link">TOP</a></li>
<li class="c-push-nav__item"><a href="#" class="c-push-nav__link">ABOUT</a></li>
<li class="c-push-nav__item"><a href="#" class="c-push-nav__link">WORKS</a></li>
<li class="c-push-nav__item"><a href="#" class="c-push-nav__link">BLOG</a></li>
<li class="c-push-nav__item"><a href="#" class="c-push-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;
--push-nav-width: 280px;
--push-transition: 0.3s ease;
/* ===== z-index 階層管理 ===== */
--z-header: 20; /* ヘッダー背景 */
--z-drawer: 40; /* プッシュナビ */
}
/* ================================================
ベースレイアウト
================================================ */
.l-container {
margin-block: 40px;
}
.l-inner {
max-width: calc(1200px + 20px * 2);
margin-inline: auto;
padding-inline: 20px;
}
@media (min-width: 768px) {
.l-inner {
padding-inline: 40px;
}
}
.p-snippet-title {
font-size: 24px;
line-height: 1.6;
font-weight: bold;
text-align: center;
}
@media (min-width: 768px) {
.p-snippet-title {
font-size: 32px;
}
}
.p-snippet-content {
margin-block-start: 24px;
font-size: 16px;
line-height: 1.8;
color: #555;
}
/* ================================================
c-push-nav: プッシュナビゲーション(右からスライドイン)
================================================ */
.c-push-nav {
position: fixed;
top: 0;
right: 0;
z-index: var(--z-drawer);
width: var(--push-nav-width);
height: 100%;
background-color: #2c2c2c;
transform: translateX(100%);
transition: transform var(--push-transition);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.24);
overflow-y: auto;
}
.c-push-nav.is-open {
transform: translateX(0);
}
@media (min-width: 768px) {
.c-push-nav {
display: none;
}
}
.c-push-nav__list {
list-style: none;
margin: 0;
padding: 0;
padding-block-start: var(--header-height-sp);
}
.c-push-nav__item {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.c-push-nav__item:last-child {
border-bottom: none;
}
.c-push-nav__link {
display: block;
padding: 16px 24px;
color: #fff;
text-decoration: none;
font-size: 15px;
font-weight: 500;
letter-spacing: 0.05em;
transition: background-color 0.2s ease;
}
@media (any-hover: hover) and (pointer: fine) {
.c-push-nav__link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
/* ================================================
c-push-wrapper: ページコンテンツラッパー
push時に左にスライドする要素(main のみを包む)
================================================ */
.c-push-wrapper {
position: relative;
min-height: 100vh;
padding-top: var(--header-height-sp);
transition: transform var(--push-transition);
}
@media (min-width: 768px) {
.c-push-wrapper {
padding-top: var(--header-height-pc);
}
}
.c-push-wrapper.is-pushed {
transform: translateX(calc(-1 * var(--push-nav-width)));
}
@media (min-width: 768px) {
.c-push-wrapper {
transform: none !important;
}
}
/* ================================================
c-push-cover: プッシュ時の全面カバー
================================================ */
.c-push-cover {
display: none;
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.c-push-wrapper.is-pushed .c-push-cover {
display: block;
pointer-events: auto;
}
@media (min-width: 768px) {
.c-push-cover {
display: none !important;
}
}
/* ================================================
c-header: ヘッダー全体(固定配置)
c-push-wrapper の外側に配置することで、wrapper の transform の影響を受けない
================================================ */
.c-header {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100%;
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;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 44px;
height: 44px;
}
@media (min-width: 768px) {
.c-hamburger {
display: none;
}
}
.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) {
.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;
}
}JavaScript(assets/js/script.js)
/**
* 005_hamburger-push
* プッシュ型ハンバーガーメニュー(コンテンツ左から押し込み型)
*/
(function () {
"use strict";
const hamburger = document.querySelector(".c-hamburger");
const pushNav = document.querySelector(".c-push-nav");
const pushWrapper = document.querySelector(".c-push-wrapper");
const pushCover = document.querySelector(".c-push-cover");
if (!hamburger || !pushNav || !pushWrapper || !pushCover) 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");
pushNav.classList.add("is-open");
pushWrapper.classList.add("is-pushed");
hamburger.setAttribute("aria-expanded", "true");
hamburger.setAttribute("aria-label", "メニューを閉じる");
pushNav.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
const firstFocusable = pushNav.querySelectorAll(FOCUSABLE_SELECTOR)[0];
if (firstFocusable) {
// 300ms = CSS の --push-transition (0.3s) と合わせる
setTimeout(function () {
firstFocusable.focus();
}, 300);
}
}
function closeMenu() {
hamburger.classList.remove("is-active");
pushNav.classList.remove("is-open");
pushWrapper.classList.remove("is-pushed");
hamburger.setAttribute("aria-expanded", "false");
hamburger.setAttribute("aria-label", "メニューを開く");
pushNav.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
}
function toggleMenu() {
const isOpen = hamburger.classList.contains("is-active");
if (isOpen) {
closeMenu();
} else {
openMenu();
}
}
function trapFocus(e) {
if (e.key !== "Tab") return;
if (!pushNav.classList.contains("is-open")) return;
const focusableElements = Array.from(pushNav.querySelectorAll(FOCUSABLE_SELECTOR));
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);
// 押し出されたコンテンツ部分(カバー要素)をクリックで閉じる
pushCover.addEventListener("click", function () {
if (!pushNav.classList.contains("is-open")) return;
closeMenu();
hamburger.focus();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && pushNav.classList.contains("is-open")) {
closeMenu();
hamburger.focus();
return;
}
if (e.key === "Tab") {
trapFocus(e);
}
});
const mediaQuery = window.matchMedia("(min-width: 768px)");
mediaQuery.addEventListener("change", function (e) {
if (e.matches) {
if (pushNav.classList.contains("is-open")) {
closeMenu();
}
pushNav.removeAttribute("aria-hidden");
} else {
pushNav.setAttribute("aria-hidden", "true");
}
});
})();カスタマイズのポイント
コードをそのまま使いつつ、見た目や動作を調整したい場合は以下の箇所を変更してください。
- ナビの幅:
:rootの--push-nav-width: 280pxを変更(CSSのナビ幅と押し出し量が連動して変わる) - スライドアニメーションの速度:
:rootの--push-transition: 0.3s easeを変更(JSのsetTimeoutの300も同じ値に合わせること) - ナビの背景色:
.c-push-navのbackground-colorを変更 - ナビ左側の影:
.c-push-navのbox-shadow: -4px 0 16px rgba(0, 0, 0, 0.24)を変更 - ブレークポイント: CSSのメディアクエリの
768pxとJSのmatchMedia("(min-width: 768px)")を両方変更する - 押し出しの向きを左→右に変えたい場合:
.c-push-navをright: 0→left: 0、transform: translateX(100%)→transform: translateX(-100%)、box-shadowのX軸符号を反転、.c-push-wrapper.is-pushedのtranslateXを正の値に変更(★要確認: 推奨はしませんが実装としては可能です)
よくある質問(FAQ)
- なぜヘッダーを
c-push-wrapperの中に入れてはいけないのですか? c-push-wrapperには push 時にtransformがかかります。CSSの仕様で、祖先要素にtransformがあると、その子孫のposition: fixedの基準が「ビューポート」ではなく「transformを持つ祖先」に変わってしまいます。結果、ヘッダーにposition: fixedを指定しても、ラッパーが左に動くと一緒に左に動いてしまい、固定ヘッダーとして機能しません。これを避けるためにヘッダーをc-push-wrapperの外に独立させています。
- なぜ
c-push-wrapper全体ではなく、わざわざカバー要素を作ってクリックを受けるのですか? c-push-wrapperの中にはリンクやボタンなどのインタラクティブ要素が含まれます。ラッパー全体にクリックリスナーを張ると、「閉じる目的でクリックしたつもりがリンクが反応する」といった誤動作が起きやすくなります。専用の透明レイヤー(c-push-cover)をis-pushedのときだけ前面に出す設計にすれば、クリックの責務を分離できて安全です。
- オーバーレイ(背景の暗幕)がないと、メニューを開いている感が弱くなりませんか?
オーバーレイの代わりに、
.c-push-nav側にbox-shadow: -4px 0 16px rgba(0, 0, 0, 0.24)を入れて、ナビの境界を視覚的に強調しています。さらにコンテンツ自体が左に逃げる動きが入ることで、「メニューを開いた」という状態は十分に伝わります。ネイティブアプリのドロワーUIに近い、すっきりした印象になります。
- JSの
setTimeout(..., 300)の300という数字は何ですか? CSSの
--push-transition: 0.3sと同じ値(300ミリ秒)です。トランジションが終わってからフォーカスを動かすために、同じ時間だけ待たせています。CSSの値を変えるときは、ここの数値も同じ値に合わせる必要があります。
- このコードはWordPressテーマでも使えますか?
使えます。即時関数
(function () { ... })();で囲んであるので、変数や関数がグローバルに漏れず、他のスクリプトと衝突しにくい作りになっています。クラス名がテーマ側と被らないことだけ確認すれば、そのまま組み込めます。
まとめ
この記事では以下の内容を解説しました。
- push型は「ナビが現れる」のではなく「コンテンツが逃げる」設計のメニュー
- ヘッダーを
c-push-wrapperの外に出す構造的工夫で、push時もヘッダーが動かない固定表示を実現 position: fixedの祖先にtransformがあると fixed が効かなくなる、というCSSの仕様を回避するための設計- カバー要素(
c-push-cover)を別に用意することで、ラッパー内のリンクやボタンとクリックを干渉させない - ナビ幅と押し出し量を
--push-nav-widthの1つのカスタムプロパティで共有することで、一箇所変更すれば両方が連動
push型は見た目のインパクトが大きいぶん、実装に取りかかると position: fixed と transform の相性で一度はつまずきやすいUIです。私自身、最初に作ったときは「ヘッダーがなぜか一緒に動いてしまう」現象でしばらく悩みました。「ヘッダーをラッパーの外に出す」という発想にたどり着いた瞬間、それまでの試行錯誤がスーッと一本につながった記憶があります。同じ場所で同じ悩みに当たった方がいたら、この記事が「あ、そういうことか」と腑に落ちるきっかけになれば嬉しいです。
まずはGitHub Pagesのデモで動きを触ってみてください。コンテンツが左に逃げる動き、ナビの影、Escapeキーで閉じたあとフォーカスがハンバーガーに戻る感触——仕組みを知ってから触ると、細部のこだわりが見えてきます。気に入ったらGitHubリポジトリからコードを持ち帰って、自分のプロジェクトに合わせてカスタマイズしてみてください。
