モダンUIコンポーネントの実装パターン
実装ガイドReact、Vue、Angularで共通して使えるUIパターンを解説
モバイルファーストなナビゲーションメニュー。スライドイン、フェード、スケールなど多彩なアニメーションと、ダーク、カラフルなどのテーマバリエーションに対応
モバイルデバイスやレスポンシブデザインに最適化されたハンバーガーメニューです。 左上のメニュー アイコンをクリックすると、スライドインメニューが表示されます。
スライド、フェード、スケール、回転など、4種類のアニメーション効果から選択可能。サイトの雰囲気に合わせた演出を実現
キーボード操作、フォーカストラップ、スクリーンリーダー対応など、すべてのユーザーが使いやすい設計
ヘッダー、フッター、カスタムアイコン、バッジ表示など、柔軟なカスタマイズオプションで様々なニーズに対応
プロンプト例:
ECサイト用のハンバーガーメニューを作成してください。カテゴリー、検索、カート、アカウントのリンクを含み、カートアイテム数のバッジ表示機能を追加してください
プロンプト例:
SaaSアプリケーション用のハンバーガーメニューを実装してください。ユーザー情報ヘッダー、通知バッジ付きメニュー項目、ダークテーマで作成してください
プロンプト例:
コーポレートサイト用のミニマルなハンバーガーメニューを作成してください。会社ロゴ、階層的なナビゲーション構造、お問い合わせCTAボタンを含めてください
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)}
/>
);
}