ハンバーガーメニュー - Vibe Coding Showcase

ハンバーガーメニュー

モバイルファーストなナビゲーションメニュー。スライドイン、フェード、スケールなど多彩なアニメーションと、ダーク、カラフルなどのテーマバリエーションに対応

デザインプレビュー

ハンバーガーメニューデモ

モバイルデバイスやレスポンシブデザインに最適化されたハンバーガーメニューです。 左上のメニューアイコンをクリックすると、スライドインメニューが表示されます。

特徴

  • スムーズなアニメーション効果
  • アクセシビリティ対応(キーボード操作、スクリーンリーダー)
  • カスタマイズ可能なテーマとスタイル
  • サブメニュー対応
  • バッジ表示機能

ハンバーガーメニューの特徴

多彩なアニメーション

スライド、フェード、スケール、回転など、4種類のアニメーション効果から選択可能。サイトの雰囲気に合わせた演出を実現

アクセシビリティ対応

キーボード操作、フォーカストラップ、スクリーンリーダー対応など、すべてのユーザーが使いやすい設計

カスタマイズ性

ヘッダー、フッター、カスタムアイコン、バッジ表示など、柔軟なカスタマイズオプションで様々なニーズに対応

AI活用ガイド

ECサイトのメニュー

プロンプト例:

ECサイト用のハンバーガーメニューを作成してください。カテゴリー、検索、カート、アカウントのリンクを含み、カートアイテム数のバッジ表示機能を追加してください

SaaSダッシュボード

プロンプト例:

SaaSアプリケーション用のハンバーガーメニューを実装してください。ユーザー情報ヘッダー、通知バッジ付きメニュー項目、ダークテーマで作成してください

コーポレートサイト

プロンプト例:

コーポレートサイト用のミニマルなハンバーガーメニューを作成してください。会社ロゴ、階層的なナビゲーション構造、お問い合わせCTAボタンを含めてください

実装コード

ファイルサイズ: 11.2KB TypeScript

コンポーネント実装

import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

export interface HamburgerMenuProps {
  items: MenuItem[];
  position?: 'left' | 'right';
  theme?: 'default' | 'minimal' | 'dark' | 'colorful';
  animation?: 'slide' | 'fade' | 'scale' | 'rotate';
  overlay?: boolean;
  overlayBlur?: boolean;
  closeOnOverlayClick?: boolean;
  closeOnEscape?: boolean;
  hamburgerColor?: string;
  menuWidth?: string;
  customHamburger?: React.ReactNode;
  customCloseButton?: React.ReactNode;
  header?: React.ReactNode;
  footer?: React.ReactNode;
  onOpen?: () => void;
  onClose?: () => void;
  className?: string;
  menuClassName?: string;
  zIndex?: number;
}

export interface MenuItem {
  id: string;
  label: string;
  href?: string;
  onClick?: () => void;
  icon?: React.ReactNode;
  badge?: string | number;
  divider?: boolean;
  disabled?: boolean;
  subItems?: MenuItem[];
}

const HamburgerMenu: React.FC<HamburgerMenuProps> = ({
  items,
  position = 'left',
  theme = 'default',
  animation = 'slide',
  overlay = true,
  overlayBlur = false,
  closeOnOverlayClick = true,
  closeOnEscape = true,
  hamburgerColor = 'currentColor',
  menuWidth = '280px',
  customHamburger,
  customCloseButton,
  header,
  footer,
  onOpen,
  onClose,
  className = '',
  menuClassName = '',
  zIndex = 50
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [isAnimating, setIsAnimating] = useState(false);
  const [isMounted, setIsMounted] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);
  const hamburgerRef = useRef<HTMLButtonElement>(null);

  // クライアントサイドでのみマウント
  useEffect(() => {
    setIsMounted(true);
  }, []);

  useEffect(() => {
    if (isOpen) {
      setIsAnimating(true);
      onOpen?.();
      
      // Trap focus
      const firstFocusable = menuRef.current?.querySelector<HTMLElement>(
        'a:not([disabled]), button:not([disabled])'
      );
      firstFocusable?.focus();
      
      // Prevent body scroll
      document.body.style.overflow = 'hidden';
    } else {
      // Restore body scroll
      document.body.style.overflow = '';
      onClose?.();
    }

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

  useEffect(() => {
    const handleEscape = (event: KeyboardEvent) => {
      if (closeOnEscape && event.key === 'Escape' && isOpen) {
        handleClose();
      }
    };

    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [closeOnEscape, isOpen]);

  const handleClose = () => {
    setIsAnimating(false);
    setTimeout(() => setIsOpen(false), 300);
    hamburgerRef.current?.focus();
  };

  const handleOverlayClick = () => {
    if (closeOnOverlayClick) {
      handleClose();
    }
  };

  const getThemeStyles = () => {
    const themes = {
      default: {
        menu: 'bg-white text-gray-900 shadow-xl',
        item: 'hover:bg-gray-100',
        activeItem: 'bg-gray-100',
        divider: 'border-gray-200',
        header: 'border-gray-200',
        footer: 'border-gray-200'
      },
      minimal: {
        menu: 'bg-white text-gray-800',
        item: 'hover:bg-gray-50',
        activeItem: 'bg-gray-50',
        divider: 'border-gray-100',
        header: 'border-gray-100',
        footer: 'border-gray-100'
      },
      dark: {
        menu: 'bg-gray-900 text-white shadow-2xl',
        item: 'hover:bg-gray-800',
        activeItem: 'bg-gray-800',
        divider: 'border-gray-700',
        header: 'border-gray-700',
        footer: 'border-gray-700'
      },
      colorful: {
        menu: 'bg-gradient-to-br from-purple-600 to-blue-600 text-white shadow-xl',
        item: 'hover:bg-white/20',
        activeItem: 'bg-white/20',
        divider: 'border-white/30',
        header: 'border-white/30',
        footer: 'border-white/30'
      }
    };

    return themes[theme];
  };

  const getAnimationStyles = () => {
    const baseTransition = 'transition-all duration-300 ease-in-out';
    
    const animations = {
      slide: {
        initial: position === 'left' ? '-translate-x-full' : 'translate-x-full',
        animate: 'translate-x-0',
        overlay: 'transition-opacity duration-300'
      },
      fade: {
        initial: 'opacity-0',
        animate: 'opacity-100',
        overlay: 'transition-opacity duration-300'
      },
      scale: {
        initial: 'scale-95 opacity-0',
        animate: 'scale-100 opacity-100',
        overlay: 'transition-opacity duration-300'
      },
      rotate: {
        initial: position === 'left' ? '-rotate-12 -translate-x-full' : 'rotate-12 translate-x-full',
        animate: 'rotate-0 translate-x-0',
        overlay: 'transition-opacity duration-300'
      }
    };

    return {
      base: baseTransition,
      ...animations[animation]
    };
  };

  const renderMenuItem = (item: MenuItem, depth: number = 0) => {
    if (item.divider) {
      return (
        <div
          key={item.id}
          className={`my-2 border-t ${getThemeStyles().divider}`}
        />
      );
    }

    const hasSubItems = item.subItems && item.subItems.length > 0;
    const paddingLeft = `${16 + depth * 16}px`;

    return (
      <div key={item.id}>
        {item.href ? (
          <a
            href={item.href}
            onClick={(e) => {
              if (item.onClick) {
                e.preventDefault();
                item.onClick();
              }
              if (!hasSubItems) {
                handleClose();
              }
            }}
            className={`flex items-center px-4 py-3 ${getThemeStyles().item} transition-colors ${
              item.disabled ? 'opacity-50 cursor-not-allowed' : ''
            }`}
            style={{ paddingLeft }}
            aria-disabled={item.disabled}
          >
            {item.icon && (
              <span className="mr-3 w-5 h-5 flex items-center justify-center">
                {item.icon}
              </span>
            )}
            <span className="flex-1">{item.label}</span>
            {item.badge && (
              <span className="ml-2 px-2 py-1 text-xs rounded-full bg-blue-500 text-white">
                {item.badge}
              </span>
            )}
            {hasSubItems && (
              <svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
              </svg>
            )}
          </a>
        ) : (
          <button
            onClick={() => {
              if (!item.disabled && item.onClick) {
                item.onClick();
                if (!hasSubItems) {
                  handleClose();
                }
              }
            }}
            className={`w-full text-left flex items-center px-4 py-3 ${getThemeStyles().item} transition-colors ${
              item.disabled ? 'opacity-50 cursor-not-allowed' : ''
            }`}
            style={{ paddingLeft }}
            disabled={item.disabled}
          >
            {item.icon && (
              <span className="mr-3 w-5 h-5 flex items-center justify-center">
                {item.icon}
              </span>
            )}
            <span className="flex-1">{item.label}</span>
            {item.badge && (
              <span className="ml-2 px-2 py-1 text-xs rounded-full bg-blue-500 text-white">
                {item.badge}
              </span>
            )}
            {hasSubItems && (
              <svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
              </svg>
            )}
          </button>
        )}
        {hasSubItems && (
          <div className="pl-4">
            {item.subItems!.map((subItem) => renderMenuItem(subItem, depth + 1))}
          </div>
        )}
      </div>
    );
  };

  const themeStyles = getThemeStyles();
  const animationStyles = getAnimationStyles();

  const menuContent = (
    <>
      {/* Overlay */}
      {overlay && isOpen && (
        <div
          className={`fixed inset-0 bg-black ${
            overlayBlur ? 'backdrop-blur-sm' : ''
          } ${animationStyles.overlay} ${
            isAnimating ? 'bg-opacity-50' : 'bg-opacity-0'
          }`}
          style={{ zIndex }}
          onClick={handleOverlayClick}
          aria-hidden="true"
        />
      )}

      {/* Menu */}
      {isOpen && (
        <div
          ref={menuRef}
          className={`fixed top-0 ${position}-0 h-full ${themeStyles.menu} ${animationStyles.base} ${
            isAnimating ? animationStyles.animate : animationStyles.initial
          } ${menuClassName}`}
          style={{ width: menuWidth, zIndex: zIndex + 1 }}
          role="navigation"
          aria-label="Mobile menu"
        >
          {/* Close button */}
          <div className="absolute top-4 right-4">
            {customCloseButton || (
              <button
                onClick={handleClose}
                className="p-2 rounded-lg hover:bg-black/10 transition-colors"
                aria-label="Close menu"
              >
                <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
                </svg>
              </button>
            )}
          </div>

          {/* Header */}
          {header && (
            <div className={`p-4 border-b ${themeStyles.header}`}>
              {header}
            </div>
          )}

          {/* Menu items */}
          <nav className="flex-1 overflow-y-auto py-4">
            {items.map((item) => renderMenuItem(item))}
          </nav>

          {/* Footer */}
          {footer && (
            <div className={`p-4 border-t ${themeStyles.footer}`}>
              {footer}
            </div>
          )}
        </div>
      )}
    </>
  );

  return (
    <>
      {/* Hamburger button */}
      <button
        ref={hamburgerRef}
        onClick={() => setIsOpen(true)}
        className={`p-2 rounded-lg hover:bg-gray-100 transition-colors ${className}`}
        aria-label="Open menu"
        aria-expanded={isOpen}
        aria-controls="mobile-menu"
      >
        {customHamburger || (
          <svg
            className="w-6 h-6"
            fill="none"
            stroke={hamburgerColor}
            viewBox="0 0 24 24"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M4 6h16M4 12h16M4 18h16"
            />
          </svg>
        )}
      </button>

      {/* Portal for menu */}
      {isMounted && createPortal(menuContent, document.body)}
    </>
  );
};

// Animated hamburger icon component
export const AnimatedHamburger: React.FC<{
  isOpen: boolean;
  color?: string;
  size?: number;
}> = ({ isOpen, color = 'currentColor', size = 24 }) => {
  return (
    <svg
      className="transition-transform duration-300"
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke={color}
      strokeWidth={2}
    >
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d={isOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"}
        className="transition-all duration-300"
      />
    </svg>
  );
};

export default HamburgerMenu;

使用例

// 基本的な使用例
import HamburgerMenu from './hamburger-menu';

const menuItems = [
  {
    id: 'home',
    label: 'ホーム',
    href: '/',
    icon: <HomeIcon />
  },
  {
    id: 'products',
    label: '製品',
    icon: <ProductIcon />,
    subItems: [
      { id: 'software', label: 'ソフトウェア', href: '/products/software' },
      { id: 'hardware', label: 'ハードウェア', href: '/products/hardware' }
    ]
  },
  {
    id: 'divider-1',
    divider: true
  },
  {
    id: 'notifications',
    label: '通知',
    badge: 3,
    icon: <BellIcon />,
    onClick: () => console.log('通知を開く')
  }
];

function App() {
  return (
    <header>
      <HamburgerMenu
        items={menuItems}
        position="left"
        theme="default"
        animation="slide"
        overlayBlur={true}
        header={<UserProfile />}
        footer={<Copyright />}
      />
    </header>
  );
}

// ダークテーマ with カスタムアニメーション
function DarkMenu() {
  return (
    <HamburgerMenu
      items={menuItems}
      position="right"
      theme="dark"
      animation="scale"
      closeOnEscape={true}
      closeOnOverlayClick={true}
      onOpen={() => console.log('メニューが開きました')}
      onClose={() => console.log('メニューが閉じました')}
    />
  );
}

// カスタムハンバーガーアイコン
import { AnimatedHamburger } from './hamburger-menu';

function CustomIconMenu() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <HamburgerMenu
      items={menuItems}
      customHamburger={<AnimatedHamburger isOpen={isOpen} />}
      onOpen={() => setIsOpen(true)}
      onClose={() => setIsOpen(false)}
    />
  );
}