【Next.js】ハンバーガーメニューのアニメーションの作り方を解説
公開日: 2026年9月12日 · 読了目安 14 分
Next.js でハンバーガーメニューのアニメーションを作る手順をコード付きで解説。三本線がバツに変わるアイコン、右から出るパネル、Escape で閉じる、背景のスクロール固定、Framer Motion での閉じる動きまで扱います。
ハンバーガーメニューのアニメーションは3つの動きで作れる
Next.js で LP を作り、スマホ表示でハンバーガーメニューを付けた。開くことはできたが、背景がスクロールしたり閉じ方が分からなかったりして使いにくい。
この記事は、そんな状況の人に向けて書いています。
ハンバーガーメニューのアニメーションは、次の3つで十分です。
三本線がバツに変わる
パネルが右から出る
背景が暗くなる
ただ、動きより先に作るものがあります。次の3つです。
Escape キーで閉じる
背景がスクロールしない
フォーカスがメニューの中に残る
この記事で分かること:
追加のライブラリなしで、Tailwind CSS だけで作るハンバーガーメニューの開閉アニメーション
三本線をバツに変えるアイコンの作り方
Escape キーで閉じる処理・背景のスクロール固定・フォーカス管理のコード
Motion(旧 Framer Motion)で閉じるときの動きまで付ける方法
ヘッダーの背景をぼかしたときに、パネルが崩れる原因
ハンバーガーメニューは閉じる仕組みを先に作る
スマホでメニューを開いた人は、目的のリンクを押すか、すぐに閉じたいかのどちらかです。アニメーションが滑らかでも、閉じ方に迷うと離脱につながります。
使いにくいハンバーガーメニューには、次の3つの原因がよく見られます。
開いたまま背面のページがスクロールし、今どこにいるか分からなくなる
Tab キーでフォーカスが背面のリンクに移り、キーボードや読み上げで操作できない
閉じる手段がボタンの再タップだけで、背景のタップや Escape キーが効かない
どれも見た目では気づきにくい問題です。そのため、実装の順番は「閉じる仕組み → アニメーション」にします。
ページ本体の page.tsx は、サーバーで組み立てる部品のまま残します。メニューのファイルにだけ "use client"(この部品はブラウザで動かす、という印)を付けます。
開閉の状態を覚えるのはメニューだけです。そのため、ページ全体をブラウザで動かす必要はありません。
Tailwind CSS でハンバーガーメニューの開閉を作る
まずはライブラリを足さずに、useState(状態を覚える仕組み)と Tailwind CSS だけで作ります。
開いているかどうかを open という状態で1つ覚えます。その値で、アイコン・背景・パネルの3つのクラスを切り替えます。
パネルは常に DOM に置き、閉じているときは invisible と translate-x-full で画面外に隠します。
次のコードは、そのまま components/MobileNav.tsx として使える完成形です。リンクの中身だけ差し替えてください。
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
const links = [
{ href: "#features", label: "機能" },
{ href: "#pricing", label: "料金" },
{ href: "#faq", label: "よくある質問" },
];
export function MobileNav() {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const pathname = usePathname();
// ページを移動したら閉じる
useEffect(() => {
setOpen(false);
}, [pathname]);
useEffect(() => {
if (!open) return;
const button = buttonRef.current;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
rootRef.current?.querySelector<HTMLElement>("nav a")?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (e.key !== "Tab" || !rootRef.current) return;
const items = rootRef.current.querySelectorAll<HTMLElement>("button, nav a");
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = prevOverflow;
window.removeEventListener("keydown", onKeyDown);
button?.focus();
};
}, [open]);
return (
<div ref={rootRef} className="md:hidden">
<button
ref={buttonRef}
type="button"
aria-expanded={open}
aria-controls="mobile-nav"
aria-label={open ? "メニューを閉じる" : "メニューを開く"}
onClick={() => setOpen((v) => !v)}
className={`relative z-[60] grid h-10 w-10 place-items-center transition-colors ${open ? "text-white" : "text-zinc-900"}`}
>
<span aria-hidden className="relative block h-4 w-6">
<span className={`absolute left-0 top-0 h-0.5 w-6 bg-current transition-transform duration-300 motion-reduce:transition-none ${open ? "translate-y-[7px] rotate-45" : ""}`} />
<span className={`absolute left-0 top-[7px] h-0.5 w-6 bg-current transition-opacity duration-200 ${open ? "opacity-0" : ""}`} />
<span className={`absolute bottom-0 left-0 h-0.5 w-6 bg-current transition-transform duration-300 motion-reduce:transition-none ${open ? "-translate-y-[7px] -rotate-45" : ""}`} />
</span>
</button>
<div
aria-hidden
onClick={() => setOpen(false)}
className={`fixed inset-0 z-40 bg-black/50 transition-opacity duration-300 ${open ? "opacity-100" : "pointer-events-none opacity-0"}`}
/>
<nav
id="mobile-nav"
aria-label="メインメニュー"
className={`fixed inset-y-0 right-0 z-50 flex h-dvh w-4/5 max-w-sm flex-col gap-6 bg-zinc-950 px-8 pt-24 text-lg text-white transition-[transform,visibility] duration-300 ease-out motion-reduce:transition-none ${open ? "visible translate-x-0" : "invisible translate-x-full"}`}
>
{links.map((link) => (
<a key={link.href} href={link.href} onClick={() => setOpen(false)}>
{link.label}
</a>
))}
</nav>
</div>
);
}ハンバーガーの三本線をバツに変えるアイコン
高さ 16px の箱に、太さ 2px の線を上・中・下と3本置きます。
開いたら、上の線を 7px 下げて 45 度回します。下の線は同じだけ上げて -45 度回し、真ん中の線は透明にします。
移動と回転を同時にかけるので、線が中央で交差してバツになります。
7px は「箱の高さ − 線の太さ」の半分です(16 − 2 = 14 の半分)。箱の高さを変えたら、この値も合わせて変えてください。
パネルは右からスライド、背景はフェードで出す
パネルは、画面の外(translate-x-full)から定位置(translate-x-0)へ 0.3 秒で動かします。
動き方は ease-out(出だしが速く、最後はゆっくり止まる)にします。指で引き出したような感触になります。
背景の黒は、透明度(opacity)だけを変えます。
閉じているパネルに invisible を付けるのは、Tab キーで画面外のリンクにフォーカスが入らないようにするためです。
transition-[transform,visibility] と書くと、閉じるときは動き終わってから非表示になります。そのため、閉じる動きも途中で消えません。
Escape キー・スクロール固定・フォーカスの処理を1か所にまとめる
メニューが開いている間(open が true の間)だけ、2つの処理を動かします。
1つは背景のスクロールを止める処理です。body の overflow を hidden にします。
もう1つは、キー入力の見張りです。keydown(キーが押されたとき)のイベントを監視します。
Escape で閉じ、Tab が最後の要素を越えたら最初に戻します。これがフォーカスを閉じ込める処理です。
閉じるときの後片付けでは、スクロールの設定を元に戻します。そしてハンバーガーボタンにフォーカスを返します。
閉じた後にフォーカスが行方不明にならないので、キーボードで操作する人も続けて使えます。
読み上げソフトの操作まで背面から切り離すには、開いている間だけ main などの背面に inert 属性を付けます。
動きを減らす設定の人にはアニメーションを止める
OS で「視差効果を減らす」などを選んでいる人には、motion-reduce:transition-none でアニメーションを止めます。開閉そのものは残るので、機能は変わりません。
Framer Motion でハンバーガーメニューの閉じる動きを作る
リンクを1つずつ少し遅らせて出したいときは、Motion が便利です。
AnimatePresence(消える要素に動きを付ける部品)で包むと、画面から消える前に閉じる動き(exit)を再生できます。
インストールするパッケージは motion で、読み込み先は motion/react です(旧名の framer-motion でも同じ API が使えます)。
キーで閉じる処理やスクロール固定は、前のコードをそのまま使えます。差し替えるのは、背景とパネルの描画部分だけです。
import { AnimatePresence, motion } from "motion/react";
// MobileNav の return 内で、背景とパネルをこれに置き換える
<AnimatePresence>
{open && (
<motion.div
key="overlay"
aria-hidden
onClick={() => setOpen(false)}
className="fixed inset-0 z-40 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
{open && (
<motion.nav
key="panel"
id="mobile-nav"
aria-label="メインメニュー"
className="fixed inset-y-0 right-0 z-50 flex h-dvh w-4/5 max-w-sm flex-col gap-6 bg-zinc-950 px-8 pt-24 text-lg text-white"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
>
{links.map((link, i) => (
<motion.a
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.12 + i * 0.05 }}
>
{link.label}
</motion.a>
))}
</motion.nav>
)}
</AnimatePresence>メニュー項目の遅延は 50ms 前後に抑える
リンクの遅延を 100ms 以上ずつにすると、最後のリンクが出るまで待たされます。5項目なら全体で 0.4 秒以内に収めると、演出より操作が先に感じられます。
この方法では、閉じているあいだパネルが DOM にありません。そのため invisible は不要です。
一方で、開いた直後のフォーカス移動は、要素が描画された後に行う必要があります。
ハンバーガーメニューを Dialog 系の部品で作る方法
次の処理をすべて自分で書くと、どこかが漏れがちです。
フォーカスの閉じ込め
スクロール固定
キーボードで閉じる
読み上げへの通知
Radix UI の Dialog のようなプリミティブ(見た目を持たない部品)は、これらを最初から備えています。
shadcn/ui を使っているなら、Sheet がこの用途に近い部品です。画面の端から出るパネルとして使え、アニメーションは Tailwind のクラスで調整できます。
管理画面のように見た目より確実さを優先する場面では、こちらを選ぶと手戻りが少なくなります。
ただし、プリミティブが用意するのは仕組みまでです。三本線のアイコンやリンクの入場、ヘッダー全体のデザインは自分で作ります。
ハンバーガーメニューでよくある失敗と直し方
ヘッダーの backdrop-blur でメニューのパネルが切れる
スクロールに合わせて半透明になるヘッダーには、backdrop-blur をよく付けます。
CSS の仕様では、backdrop-filter や transform を持つ要素の中にある fixed 要素は、画面ではなくその要素を基準に配置されます。その結果、パネルがヘッダーの高さに閉じ込められます。
対策は2つあります。1つはぼかしをヘッダー本体ではなく、背面に敷いた別の div に付ける方法です。
もう1つは、React の createPortal でパネルを body 直下に描画する方法です。ヘッダーの作り方全体は https://design-layer.com/ja/articles/nextjs-modern-header-guide で解説しています。
100vh だとメニューの下が切れる
スマホのブラウザは、アドレスバーの出し入れで画面の高さが変わります。h-screen(100vh)だとパネルの下端が隠れることがあるため、h-dvh を使います。
ページを移動してもメニューが開いたまま
App Router の Link で移動すると、レイアウトに置いたヘッダーは再マウントされません。usePathname の値が変わったら閉じる処理を入れておくと、移動先で開いたまま残りません。
md 以上の画面でもハンバーガーが残る
メニュー全体に md:hidden を付け、デスクトップ用のリンクには hidden md:flex を付けます。境目の幅で両方が出ていないか、ブラウザの幅を変えて確認してください。
ハンバーガーメニュー付きの完成ヘッダーを使う方法

デスクトップでは透明な固定ヘッダー、スマホではフルスクリーンで開くメニュー。この組み合わせを一から作ると、この記事のコードにヘッダーの実装が加わり、調整に時間がかかります。
DesignLayer は、人が組んだ React + Tailwind のモーション付きセクションを、ブラウザでライブプレビューしてからコピーできるカタログです。
閲覧はアカウントもカードも不要です。https://design-layer.com/ja/components
無料サンプル10個を無料アカウントで回数制限なくコピーでき、動画プロンプトは月1回です。Free では MCP に接続できません。カタログ全体は30日ビルドパス($19の1回払い、自動更新なし)、Lifetime Access(先着100名の$79の1回払い)、Plus(月額 $9、いつでも解約可)です。無料サンプル以外のナビとヒーローは、30日ビルドパス($19)、Lifetime($79)、Plus(月額 $9)です。
コピー後はリンクの文言と行き先だけを差し替えます。
ハンバーガーメニューのアニメーションでよくある質問
作れます。この記事の最初のコードは、useState と Tailwind CSS のトランジションだけで動いています。
リンクを1つずつ遅らせる演出や、閉じる動きを細かく制御したいときに Motion を足します。
まとめ:Next.js でハンバーガーメニューを作る手順
ハンバーガーメニューのアニメーションは、バツに変わるアイコン・右から出るパネル・暗くなる背景の3つで足りる
先に作るのは、キーで閉じる処理・背景のスクロール固定・フォーカスの閉じ込め
閉じる動きやリンクの順番表示は Motion で作る
背景をぼかしたヘッダーの中ではパネルが切れるので、ぼかしを分けるか、パネルを body 直下に出す
まずはこの記事の MobileNav をそのまま貼り、スマホ実機で開閉を試してください。
ヘッダー全体を整えるなら https://design-layer.com/ja/articles/nextjs-modern-header-guide 、動きの基本は https://design-layer.com/ja/articles/nextjs-framer-motion-guide が参考になります。


