😎

React ハンバーガーメニュー

に公開

個人開発でシンプルにしたい画面が、機能が増えてごちゃごちゃしてきたのでハンバーガーメニューを実装することにしました。

完成イメージ

この記事で作成するハンバーガーメニューの特徴:

  • TypeScriptで型安全な実装
  • アニメーション付きの開閉動作
  • オーバーレイクリックで閉じる
  • アクセシビリティ対応
  • クリーンで再利用可能なコンポーネント

基本的な実装

1. コンポーネントの構造

まずは、TypeScriptのインターフェースと基本構造から見ていきましょう。

interface HamburgerMenuProps {
  onShowWineList: () => void;
  onShowAddForm: () => void;
}

const HamburgerMenu: React.FC<HamburgerMenuProps> = ({ 
  onShowWineList, 
  onShowAddForm 
}) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="hamburger-menu">
      {/* メニューの内容 */}
    </div>
  );
};

export default HamburgerMenu;

ポイント:

  • Propsをインターフェースで定義し、型安全性を確保
  • 親コンポーネントから関数を受け取る設計
  • React.FCで関数コンポーネントの型を明示

2. 開閉状態の管理

useStateフックを使ってメニューの開閉状態を管理します。

const [isOpen, setIsOpen] = useState(false);

const toggleMenu = () => {
  setIsOpen(!isOpen);
};

useState の使い方:

  • isOpen: 現在の状態(true = 開いている、false = 閉じている)
  • setIsOpen: 状態を更新する関数
  • toggleMenu: クリックで状態を反転させる関数

3. ハンバーガーボタンの実装

3本線のアイコンを実装します。

<button
  className={`hamburger-button ${isOpen ? 'open' : ''}`}
  onClick={toggleMenu}
  aria-label="メニュー"
>
  <span className="hamburger-line"></span>
  <span className="hamburger-line"></span>
  <span className="hamburger-line"></span>
</button>

重要なポイント:

  • テンプレートリテラルで動的にクラスを切り替え
  • aria-labelでアクセシビリティに配慮
  • 3つのspan要素で3本線を表現

メニュードロップダウンの実装

4. 条件付きレンダリング

メニューが開いている時だけドロップダウンを表示します。

{isOpen && (
  <>
    <div className="menu-overlay" onClick={() => setIsOpen(false)} />
    <div className="menu-dropdown">
      {/* メニュー項目 */}
    </div>
  </>
)}

条件付きレンダリングの仕組み:

  • {isOpen && <Component />}: isOpentrueの時だけコンポーネントを描画
  • <>...</>: Fragmentを使って複数要素をグループ化
  • オーバーレイとドロップダウンを同時に表示

5. オーバーレイの実装

背景クリックでメニューを閉じる機能を追加します。

<div 
  className="menu-overlay" 
  onClick={() => setIsOpen(false)} 
/>

UXのポイント:

  • 半透明の背景(オーバーレイ)を配置
  • クリックするとメニューが閉じる
  • ユーザーが直感的に操作できる

6. メニュー項目の実装

const handleMenuItemClick = (action: () => void) => {
  action();
  setIsOpen(false);
};

// メニュー項目
<button
  className="menu-item"
  onClick={() => handleMenuItemClick(onShowAddForm)}
>
  📝 感想を追加
</button>
<button
  className="menu-item"
  onClick={() => handleMenuItemClick(onShowWineList)}
>
  📋 レビュー一覧
</button>

実装のポイント:

  • handleMenuItemClick: アクションを実行した後、メニューを閉じる
  • 絵文字でアイコン代わりに視認性を向上
  • 各ボタンに異なるアクションを割り当て

完成系

import React, { useState } from 'react';

interface HamburgerMenuProps {
  onShowWineList: () => void;
  onShowAddForm: () => void;
}

const HamburgerMenu: React.FC<HamburgerMenuProps> = ({ 
  onShowWineList, 
  onShowAddForm 
}) => {
  const [isOpen, setIsOpen] = useState(false);

  const toggleMenu = () => {
    setIsOpen(!isOpen);
  };

  const handleMenuItemClick = (action: () => void) => {
    action();
    setIsOpen(false);
  };

  return (
    <div className="hamburger-menu">
      <button
        className={`hamburger-button ${isOpen ? 'open' : ''}`}
        onClick={toggleMenu}
        aria-label="メニュー"
      >
        <span className="hamburger-line"></span>
        <span className="hamburger-line"></span>
        <span className="hamburger-line"></span>
      </button>

      {isOpen && (
        <>
          <div className="menu-overlay" onClick={() => setIsOpen(false)} />
          <div className="menu-dropdown">
            <button
              className="menu-item"
              onClick={() => handleMenuItemClick(onShowAddForm)}
            >
              📝 感想を追加
            </button>
            <button
              className="menu-item"
              onClick={() => handleMenuItemClick(onShowWineList)}
            >
              📋 レビュー一覧
            </button>
          </div>
        </>
      )}
    </div>
  );
};

export default HamburgerMenu;

CSSスタイリング

アニメーション付きのスタイルを実装します。

/* ハンバーガーメニューのコンテナ */
.hamburger-menu {
  position: relative;
  z-index: 1000;
}

/* ハンバーガーボタン */
.hamburger-button {
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  width: 40px;
  height: 40px;
  background: transparent;
  border: none;
  cursor: pointer;
  padding: 8px;
  z-index: 1001;
}

/* 3本線 */
.hamburger-line {
  width: 100%;
  height: 3px;
  background-color: #333;
  border-radius: 2px;
  transition: all 0.3s ease;
}

/* 開いた時のアニメーション(×マーク) */
.hamburger-button.open .hamburger-line:nth-child(1) {
  transform: translateY(10px) rotate(45deg);
}

.hamburger-button.open .hamburger-line:nth-child(2) {
  opacity: 0;
}

.hamburger-button.open .hamburger-line:nth-child(3) {
  transform: translateY(-10px) rotate(-45deg);
}

/* オーバーレイ */
.menu-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
  z-index: 999;
  animation: fadeIn 0.3s ease;
}

/* ドロップダウンメニュー */
.menu-dropdown {
  position: absolute;
  top: 50px;
  right: 0;
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  min-width: 200px;
  overflow: hidden;
  z-index: 1001;
  animation: slideDown 0.3s ease;
}

/* メニュー項目 */
.menu-item {
  width: 100%;
  padding: 16px 20px;
  background: none;
  border: none;
  text-align: left;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.2s ease;
  display: flex;
  align-items: center;
  gap: 8px;
}

.menu-item:hover {
  background-color: #f5f5f5;
}

.menu-item:not(:last-child) {
  border-bottom: 1px solid #e0e0e0;
}

/* アニメーション */
@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes slideDown {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

CSSのポイント:

  • transformで×マークへのアニメーション
  • position: fixedでオーバーレイを全画面に配置
  • @keyframesでスムーズなアニメーション
  • transitionでホバー時の変化を滑らかに

親コンポーネントでの使用例

import React, { useState } from 'react';
import HamburgerMenu from './HamburgerMenu';

const App: React.FC = () => {
  const [view, setView] = useState<'home' | 'list' | 'form'>('home');

  const showWineList = () => {
    setView('list');
  };

  const showAddForm = () => {
    setView('form');
  };

  return (
    <div className="app">
      <header>
        <h1>ワインレビュー</h1>
        <HamburgerMenu
          onShowWineList={showWineList}
          onShowAddForm={showAddForm}
        />
      </header>
      
      <main>
        {view === 'home' && <HomePage />}
        {view === 'list' && <WineList />}
        {view === 'form' && <AddForm />}
      </main>
    </div>
  );
};

export default App;

応用編:より高度な機能

1. 外部クリックで閉じる(useEffect活用)

import { useEffect, useRef } from 'react';

const HamburgerMenu: React.FC<HamburgerMenuProps> = ({ 
  onShowWineList, 
  onShowAddForm 
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
        setIsOpen(false);
      }
    };

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isOpen]);

  return (
    <div className="hamburger-menu" ref={menuRef}>
      {/* コンポーネント内容 */}
    </div>
  );
};

2. キーボード操作対応(Escキーで閉じる)

useEffect(() => {
  const handleEscKey = (event: KeyboardEvent) => {
    if (event.key === 'Escape') {
      setIsOpen(false);
    }
  };

  if (isOpen) {
    document.addEventListener('keydown', handleEscKey);
  }

  return () => {
    document.removeEventListener('keydown', handleEscKey);
  };
}, [isOpen]);

3. スクロール無効化

メニューが開いている時、背景のスクロールを無効にします。

useEffect(() => {
  if (isOpen) {
    document.body.style.overflow = 'hidden';
  } else {
    document.body.style.overflow = 'unset';
  }

  return () => {
    document.body.style.overflow = 'unset';
  };
}, [isOpen]);

4. カスタムフック化

再利用性を高めるため、ロジックをカスタムフックに切り出します。

// useHamburgerMenu.ts
import { useState, useEffect, useRef } from 'react';

export const useHamburgerMenu = () => {
  const [isOpen, setIsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);

  const toggleMenu = () => {
    setIsOpen(!isOpen);
  };

  const closeMenu = () => {
    setIsOpen(false);
  };

  // 外部クリックで閉じる
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
        setIsOpen(false);
      }
    };

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
      document.body.style.overflow = 'hidden';
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
      document.body.style.overflow = 'unset';
    };
  }, [isOpen]);

  // Escキーで閉じる
  useEffect(() => {
    const handleEscKey = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        setIsOpen(false);
      }
    };

    if (isOpen) {
      document.addEventListener('keydown', handleEscKey);
    }

    return () => {
      document.removeEventListener('keydown', handleEscKey);
    };
  }, [isOpen]);

  return { isOpen, toggleMenu, closeMenu, menuRef };
};

使用例:

const HamburgerMenu: React.FC<HamburgerMenuProps> = ({ 
  onShowWineList, 
  onShowAddForm 
}) => {
  const { isOpen, toggleMenu, closeMenu, menuRef } = useHamburgerMenu();

  const handleMenuItemClick = (action: () => void) => {
    action();
    closeMenu();
  };

  return (
    <div className="hamburger-menu" ref={menuRef}>
      {/* ... */}
    </div>
  );
};

よくあるミスと対策

1. イベントリスナーのクリーンアップ忘れ

// ❌ 悪い例:クリーンアップなし
useEffect(() => {
  document.addEventListener('keydown', handleEscKey);
}, [isOpen]);

// ✅ 良い例:クリーンアップ関数を返す
useEffect(() => {
  if (isOpen) {
    document.addEventListener('keydown', handleEscKey);
  }
  
  return () => {
    document.removeEventListener('keydown', handleEscKey);
  };
}, [isOpen]);

2. 状態更新のタイミング

// ❌ 悪い例:非同期処理の考慮なし
const handleMenuItemClick = (action: () => void) => {
  setIsOpen(false);
  action(); // メニューが閉じる前に実行される可能性
};

// ✅ 良い例:順序を保証
const handleMenuItemClick = (action: () => void) => {
  action();
  setIsOpen(false);
};

パフォーマンス最適化

useCallbackでメモ化

関数の再生成を防ぎます。

import { useCallback } from 'react';

const toggleMenu = useCallback(() => {
  setIsOpen(prev => !prev);
}, []);

const handleMenuItemClick = useCallback((action: () => void) => {
  action();
  setIsOpen(false);
}, []);

まとめ

シンプルにできました

Reactでハンバーガーメニューを実装する際のポイント:

基本実装:

  • useStateで開閉状態を管理
  • 条件付きレンダリングでメニューを表示/非表示
  • オーバーレイクリックで閉じる UX

アクセシビリティ:

  • aria-labelでスクリーンリーダー対応
  • キーボード操作(Escキー)のサポート
  • 適切なセマンティックHTML

高度な機能:

  • 外部クリック検知
  • スクロール制御
  • カスタムフック化

この実装パターンは、モバイルメニューだけでなく、ドロップダウンやモーダルなど、様々なUIコンポーネントに応用できます。

参考リンク

Discussion