【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 として使える完成形です。リンクの中身だけ差し替えてください。

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 が使えます)。

キーで閉じる処理やスクロール固定は、前のコードをそのまま使えます。差し替えるのは、背景とパネルの描画部分だけです。

components/MobileNavMotion.tsx(描画部分)
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 のコンポーネント一覧
ナビで絞り込んだ DesignLayer のコンポーネント一覧

デスクトップでは透明な固定ヘッダー、スマホではフルスクリーンで開くメニュー。この組み合わせを一から作ると、この記事のコードにヘッダーの実装が加わり、調整に時間がかかります。

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 が参考になります。

関連ガイド