Shared/MobileMenuToggle
Closed
Comparison
Default
Interactive
Open
With Aria Label
mobile-menu-toggle.tsx
interface MobileMenuToggleProps {
isOpen: boolean;
onToggle: () => void;
className?: string;
'aria-label'?: string;
}
const MobileMenuToggle = ({
isOpen,
onToggle,
className = '',
'aria-label': ariaLabel,
}: MobileMenuToggleProps) => {
return (
<button
type="button"
onClick={onToggle}
className={`zd-invert-color-link cursor-pointer relative z-50 flex w-[40px] h-[40px] flex-col items-center justify-center focus:outline-none text-zd-white ${className}`}
aria-expanded={isOpen}
aria-label={ariaLabel || 'Toggle menu'}
>
<span className="sr-only">{isOpen ? 'Close menu' : 'Open menu'}</span>
<div className="relative flex h-[20px] w-[28px] flex-col items-center justify-center">
<span
className={`absolute h-[2px] w-[28px] transform bg-current transition-all duration-300 ease-in-out ${isOpen ? 'translate-y-0 rotate-45' : '-translate-y-[8px]'}`}
/>
<span
className={`absolute h-[2px] w-[28px] transform bg-current transition-all duration-300 ease-in-out ${isOpen ? 'scale-0 opacity-0' : ''}`}
/>
<span
className={`absolute h-[2px] w-[28px] transform bg-current transition-all duration-300 ease-in-out ${isOpen ? 'translate-y-0 -rotate-45' : 'translate-y-[8px]'}`}
/>
</div>
</button>
);
};
export default MobileMenuToggle;
mobile-menu-toggle.stories.tsx
import { useState } from 'preact/hooks';
import MobileMenuToggle from './mobile-menu-toggle';
import type { ControlsMap } from '../../sub-packages/styleguide-v2/src/data/control-types';
/**
* MobileMenuToggle Component
*
* An animated hamburger menu toggle button that transforms
* into an X when open.
*
* Features:
* - Smooth CSS animation between open/closed states
* - Accessible with aria-expanded and aria-label
* - Screen reader text for state changes
*/
export const meta = {
title: 'Shared/MobileMenuToggle',
};
export const controls: ControlsMap = {
isOpen: { type: 'boolean', default: false },
};
/**
* Interactive wrapper to demonstrate toggle behavior
*/
const InteractiveToggle = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="flex flex-col items-center gap-vgap-md">
<MobileMenuToggle isOpen={isOpen} onToggle={() => setIsOpen(!isOpen)} />
<p className="text-zd-white text-sm">
State: <span className="font-bold">{isOpen ? 'Open (X)' : 'Closed (≡)'}</span>
</p>
<p className="text-muted text-xs">Click to toggle</p>
</div>
);
};
/**
* Default - Static state controlled by props panel
*/
export const Default = {
args: { isOpen: false },
render: ({ isOpen }: { isOpen: boolean }) => (
<MobileMenuToggle isOpen={isOpen} onToggle={() => {}} />
),
};
/**
* Interactive toggle
*/
export const Interactive = () => <InteractiveToggle />;
/**
* Closed state (hamburger icon)
*/
export const Closed = () => <MobileMenuToggle isOpen={false} onToggle={() => {}} />;
/**
* Open state (X icon)
*/
export const Open = () => <MobileMenuToggle isOpen={true} onToggle={() => {}} />;
/**
* Side by side comparison
*/
export const Comparison = () => (
<div className="flex items-center gap-hgap-lg">
<div className="flex flex-col items-center gap-vgap-xs">
<MobileMenuToggle isOpen={false} onToggle={() => {}} />
<span className="text-muted text-xs">Closed</span>
</div>
<div className="flex flex-col items-center gap-vgap-xs">
<MobileMenuToggle isOpen={true} onToggle={() => {}} />
<span className="text-muted text-xs">Open</span>
</div>
</div>
);
/**
* With custom aria-label
*/
export const WithAriaLabel = () => (
<MobileMenuToggle isOpen={false} onToggle={() => {}} aria-label="ナビゲーションメニューを開く" />
);