All PromptsAll Prompts
ui componentnav
Staggered Menu
UI компонент: выпадающее меню с многоуровневой анимацией входа. Идеально для навигации с плавными переходами.
by Zhou JasonLive Preview
Prompt
# Staggered Menu
You are given a task to integrate an existing React component in the codebase
~~~/README.md
# StaggeredMenu
A high-performance, premium staggered menu system built with React and GSAP. It features multiple background layers that transition in sequence to create a sophisticated revealing effect.
## Features
- **GSAP Powered**: Smooth, hardware-accelerated animations for entrance and exit.
- **Staggered Layers**: Multiple background panels that slide in at different offsets.
- **Responsive**: Adapts from full-screen mobile to elegant side-panels on desktop.
- **Customizable**: Control colors, accent, position (left/right), and content.
- **Interactive Button**: Custom-animated menu toggle with text cycling and icon rotation.
## Dependencies
- `react`: ^18.2.0
- `gsap`: ^3.12.5
- `lucide-react`: ^0.454.0
- `clsx`: ^2.1.1
- `tailwind-merge`: ^2.5.4
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `items` | `StaggeredMenuItem[]` | `[]` | Primary navigation items with labels and links. |
| `socialItems` | `StaggeredMenuSocialItem[]` | `[]` | Social media links shown at the bottom. |
| `position` | `'left' \| 'right'` | `'right'` | Side from which the menu appears. |
| `colors` | `string[]` | `['#1a1a1b', '#2a2a2b', '#ffffff']` | Background colors for the staggered layers. Last one is the main panel. |
| `accentColor` | `string` | `'#5227FF'` | Brand color for highlights and hover states. |
| `logo` | `React.ReactNode` | `GlobeIcon` | Custom logo component for the header. |
| `isFixed` | `boolean` | `false` | If true, menu covers the entire viewport. |
| `displayItemNumbering`| `boolean` | `true` | Show sequential numbers ('01', '02') next to items. |
## Usage
```tsx
import { StaggeredMenu } from '@/sd-components/52ae8634-4d5f-4edd-9022-f4ebbb4df247';
const App = () => {
return (
<StaggeredMenu
items={[
{ label: 'Work', link: '/work', ariaLabel: 'View work' },
{ label: 'About', link: '/about', ariaLabel: 'About us' }
]}
colors={['#000', '#5227FF', '#fff']}
accentColor="#5227FF"
/>
);
}
```
~~~
~~~/src/App.tsx
import React from 'react';
import { StaggeredMenu } from './Component';
import { Sparkles } from 'lucide-react';
export default function App() {
const menuItems = [
{ label: 'Collection', ariaLabel: 'View our collections', link: '#' },
{ label: 'Archive', ariaLabel: 'Browse archives', link: '#' },
{ label: 'Identity', ariaLabel: 'Our brand identity', link: '#' },
{ label: 'Contact', ariaLabel: 'Get in touch', link: '#' }
];
const socialItems = [
{ label: 'Instagram', link: 'https://instagram.com' },
{ label: 'Twitter', link: 'https://twitter.com' },
{ label: 'LinkedIn', link: 'https://linkedin.com' },
{ label: 'GitHub', link: 'https://github.com' }
];
return (
<div className="min-h-screen bg-[#F9F9F9] flex items-center justify-center p-20">
<div className="w-full max-w-5xl aspect-video relative rounded-3xl overflow-hidden shadow-2xl bg-white border border-border/50">
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center space-y-4">
<h1 className="text-6xl font-black tracking-tighter text-foreground/5 uppercase">
Staggered<br />Experience
</h1>
<p className="text-muted-foreground font-medium uppercase tracking-widest text-xs">
Open the menu to interact
</p>
</div>
</div>
<StaggeredMenu
items={menuItems}
socialItems={socialItems}
logo={
<div className="flex items-center gap-2 group cursor-pointer">
<div className="w-10 h-10 rounded-full bg-black flex items-center justify-center transition-transform group-hover:rotate-12">
<Sparkles className="w-5 h-5 text-white" />
</div>
<span className="font-bold tracking-tighter text-xl uppercase hidden sm:block">Studio</span>
</div>
}
colors={['#1a1a1b', '#3b82f6', '#ffffff']}
accentColor="#3b82f6"
menuButtonColor="#000000"
openMenuButtonColor="#000000"
/>
</div>
</div>
);
}
~~~
~~~/package.json
{
"name": "@sd-components/staggered-menu",
"description": "A premium staggered menu with multi-layer entrance animations using GSAP.",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"gsap": "^3.12.5",
"lucide-react": "^0.454.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.4"
}
}
~~~
~~~/src/Component.tsx
/**
* StaggeredMenu Component
*
* A high-end navigation menu with staggered entrance animations,
* multiple background layers, and customizable social links.
* Uses GSAP for high-performance animations.
*/
import React, { useCallback, useLayoutEffect, useRef, useState, useEffect } from 'react';
import { gsap } from 'gsap';
import { Menu, X, Globe } from 'lucide-react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export interface StaggeredMenuItem {
label: string;
ariaLabel: string;
link: string;
}
export interface StaggeredMenuSocialItem {
label: string;
link: string;
}
export interface StaggeredMenuProps {
/** Position of the menu panel */
position?: 'left' | 'right';
/** Background colors for the staggered layers */
colors?: string[];
/** Primary navigation items */
items?: StaggeredMenuItem[];
/** Social media links */
socialItems?: StaggeredMenuSocialItem[];
/** Whether to show social links section */
displaySocials?: boolean;
/** Whether to show '01', '02' numbering next to items */
displayItemNumbering?: boolean;
/** Custom class for the wrapper */
className?: string;
/** Custom logo or icon to display in the header */
logo?: React.ReactNode;
/** Color of the menu button when closed */
menuButtonColor?: string;
/** Color of the menu button when open */
openMenuButtonColor?: string;
/** Brand accent color for highlights */
accentColor?: string;
/** Whether the menu wrapper should be fixed to viewport */
isFixed?: boolean;
/** Whether to animate the menu button color transition */
changeMenuColorOnOpen?: boolean;
/** Close menu when clicking outside the panel */
closeOnClickAway?: boolean;
/** Callback when menu starts opening */
onMenuOpen?: () => void;
/** Callback when menu starts closing */
onMenuClose?: () => void;
}
export const StaggeredMenu: React.FC<StaggeredMenuProps> = ({
position = 'right',
colors = ['#1a1a1b', '#2a2a2b', '#ffffff'],
items = [],
socialItems = [],
displaySocials = true,
displayItemNumbering = true,
className,
logo,
menuButtonColor = 'currentColor',
openMenuButtonColor = '#000000',
changeMenuColorOnOpen = true,
accentColor = '#5227FF',
isFixed = false,
closeOnClickAway = true,
onMenuOpen,
onMenuClose
}) => {
const [open, setOpen] = useState(false);
const openRef = useRef(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const preLayersRef = useRef<HTMLDivElement | null>(null);
const preLayerElsRef = useRef<HTMLElement[]>([]);
const plusHRef = useRef<HTMLSpanElement | null>(null);
const plusVRef = useRef<HTMLSpanElement | null>(null);
const iconRef = useRef<HTMLSpanElement | null>(null);
const textInnerRef = useRef<HTMLSpanElement | null>(null);
const [textLines, setTextLines] = useState<string[]>(['Menu', 'Close']);
const openTlRef = useRef<gsap.core.Timeline | null>(null);
const closeTweenRef = useRef<gsap.core.Tween | null>(null);
const spinTweenRef = useRef<gsap.core.Timeline | null>(null);
const textCycleAnimRef = useRef<gsap.core.Tween | null>(null);
const colorTweenRef = useRef<gsap.core.Tween | null>(null);
const toggleBtnRef = useRef<HTMLButtonElement | null>(null);
const busyRef = useRef(false);
useLayoutEffect(() => {
const ctx = gsap.context(() => {
const panel = panelRef.current;
const preContainer = preLayersRef.current;
const plusH = plusHRef.current;
const plusV = plusVRef.current;
const icon = iconRef.current;
const textInner = textInnerRef.current;
if (!panel || !plusH || !plusV || !icon || !textInner) return;
let preLayers: HTMLElement[] = [];
if (preContainer) {
preLayers = Array.from(preContainer.querySelectorAll('.sm-prelayer')) as HTMLElement[];
}
preLayerElsRef.current = preLayers;
const offscreen = position === 'left' ? -100 : 100;
gsap.set([panel, ...preLayers], { xPercent: offscreen });
gsap.set(plusH, { transformOrigin: '50% 50%', rotate: 0 });
gsap.set(plusV, { transformOrigin: '50% 50%', rotate: 90 });
gsap.set(icon, { rotate: 0, transformOrigin: '50% 50%' });
gsap.set(textInner, { yPercent: 0 });
if (toggleBtnRef.current) gsap.set(toggleBtnRef.current, { color: menuButtonColor });
});
return () => ctx.revert();
}, [menuButtonColor, position]);
const buildOpenTimeline = useCallback(() => {
const panel = panelRef.current;
const layers = preLayerElsRef.current;
if (!panel) return null;
openTlRef.current?.kill();
if (closeTweenRef.current) {
closeTweenRef.current.kill();
closeTweenRef.current = null;
}
const itemEls = Array.from(panel.querySelectorAll('.sm-panel-itemLabel')) as HTMLElement[];
const socialTitle = panel.querySelector('.sm-socials-title') as HTMLElement | null;
const socialLinks = Array.from(panel.querySelectorAll('.sm-socials-link')) as HTMLElement[];
const layerStates = layers.map(el => ({ el, start: Number(gsap.getProperty(el, 'xPercent')) }));
const panelStart = Number(gsap.getProperty(panel, 'xPercent'));
if (itemEls.length) gsap.set(itemEls, { yPercent: 140, rotate: 10 });
if (socialTitle) gsap.set(socialTitle, { opacity: 0 });
if (socialLinks.length) gsap.set(socialLinks, { y: 25, opacity: 0 });
const tl = gsap.timeline({ paused: true });
layerStates.forEach((ls, i) => {
tl.fromTo(ls.el, { xPercent: ls.start }, { xPercent: 0, duration: 0.5, ease: 'power4.out' }, i * 0.07);
});
const lastTime = layerStates.length ? (layerStates.length - 1) * 0.07 : 0;
const panelInsertTime = lastTime + (layerStates.length ? 0.08 : 0);
const panelDuration = 0.65;
tl.fromTo(
panel,
{ xPercent: panelStart },
{ xPercent: 0, duration: panelDuration, ease: 'power4.out' },
panelInsertTime
);
if (itemEls.length) {
const itemsStart = panelInsertTime + panelDuration * 0.15;
tl.to(
itemEls,
{
yPercent: 0,
rotate: 0,
duration: 1,
ease: 'power4.out',
stagger: { each: 0.1, from: 'start' }
},
itemsStart
);
}
if (socialTitle || socialLinks.length) {
const socialsStart = panelInsertTime + panelDuration * 0.4;
if (socialTitle) tl.to(socialTitle, { opacity: 1, duration: 0.5, ease: 'power2.out' }, socialsStart);
if (socialLinks.length) {
tl.to(
socialLinks,
{
y: 0,
opacity: 1,
duration: 0.55,
ease: 'power3.out',
stagger: { each: 0.08, from: 'start' }
},
socialsStart + 0.04
);
}
}
openTlRef.current = tl;
return tl;
}, [position]);
const playOpen = useCallback(() => {
if (busyRef.current) return;
busyRef.current = true;
const tl = buildOpenTimeline();
if (tl) {
tl.eventCallback('onComplete', () => {
busyRef.current = false;
});
tl.play(0);
} else {
busyRef.current = false;
}
}, [buildOpenTimeline]);
const playClose = useCallback(() => {
openTlRef.current?.kill();
openTlRef.current = null;
const panel = panelRef.current;
const layers = preLayerElsRef.current;
if (!panel) return;
const all: HTMLElement[] = [...layers, panel];
closeTweenRef.current?.kill();
const offscreen = position === 'left' ? -100 : 100;
closeTweenRef.current = gsap.to(all, {
xPercent: offscreen,
duration: 0.35,
ease: 'power3.in',
stagger: {
each: 0.05,
from: 'end'
},
overwrite: 'auto',
onComplete: () => {
busyRef.current = false;
}
});
}, [position]);
const animateIcon = useCallback((opening: boolean) => {
const icon = iconRef.current;
const h = plusHRef.current;
const v = plusVRef.current;
if (!icon || !h || !v) return;
spinTweenRef.current?.kill();
if (opening) {
spinTweenRef.current = gsap.timeline({ defaults: { ease: 'power4.out' } })
.to(h, { rotate: 45, duration: 0.5 }, 0)
.to(v, { rotate: -45, duration: 0.5 }, 0);
} else {
spinTweenRef.current = gsap.timeline({ defaults: { ease: 'power3.inOut' } })
.to(h, { rotate: 0, duration: 0.35 }, 0)
.to(v, { rotate: 90, duration: 0.35 }, 0);
}
}, []);
const animateColor = useCallback((opening: boolean) => {
const btn = toggleBtnRef.current;
if (!btn) return;
colorTweenRef.current?.kill();
if (changeMenuColorOnOpen) {
const targetColor = opening ? openMenuButtonColor : menuButtonColor;
colorTweenRef.current = gsap.to(btn, { color: targetColor, delay: 0.18, duration: 0.3, ease: 'power2.out' });
}
}, [openMenuButtonColor, menuButtonColor, changeMenuColorOnOpen]);
const animateText = useCallback((opening: boolean) => {
const inner = textInnerRef.current;
if (!inner) return;
textCycleAnimRef.current?.kill();
const targetLabel = opening ? 'Close' : 'Menu';
const seq = opening ? ['Menu', '...', 'Close'] : ['Close', '...', 'Menu'];
setTextLines(seq);
gsap.set(inner, { yPercent: 0 });
const lineCount = seq.length;
const finalShift = ((lineCount - 1) / lineCount) * 100;
textCycleAnimRef.current = gsap.to(inner, {
yPercent: -finalShift,
duration: 0.5,
ease: 'power4.out'
});
}, []);
const toggleMenu = useCallback(() => {
const target = !openRef.current;
openRef.current = target;
setOpen(target);
if (target) {
onMenuOpen?.();
playOpen();
} else {
onMenuClose?.();
playClose();
}
animateIcon(target);
animateColor(target);
animateText(target);
}, [playOpen, playClose, animateIcon, animateColor, animateText, onMenuOpen, onMenuClose]);
useEffect(() => {
if (!closeOnClickAway || !open) return;
const handleClickOutside = (event: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(event.target as Node) &&
toggleBtnRef.current && !toggleBtnRef.current.contains(event.target as Node)) {
toggleMenu();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [closeOnClickAway, open, toggleMenu]);
return (
<div className={cn(
"sm-scope overflow-hidden select-none font-sans",
isFixed ? "fixed inset-0 z-[100]" : "relative w-full h-full min-h-[600px]",
className
)}>
<div
className="staggered-menu-wrapper w-full h-full pointer-events-none"
style={{ '--sm-accent': accentColor } as React.CSSProperties}
data-position={position}
>
{/* Layer Backgrounds */}
<div ref={preLayersRef} className={cn(
"sm-prelayers absolute top-0 bottom-0 pointer-events-none z-[5] w-[100vw] sm:w-[50vw] md:w-[40vw] lg:w-[30vw]",
position === 'left' ? 'left-0' : 'right-0'
)}>
{colors.slice(0, -1).map((c, i) => (
<div
key={i}
className="sm-prelayer absolute inset-0"
style={{ background: c }}
/>
))}
</div>
{/* Header with Logo & Toggle */}
<header className="absolute top-0 left-0 w-full flex items-center justify-between p-8 sm:p-12 z-[20] pointer-events-none">
<div className="pointer-events-auto">
{logo || (
<div className="w-10 h-10 rounded-full bg-foreground/10 flex items-center justify-center">
<Globe className="w-6 h-6 text-foreground" />
</div>
)}
</div>
<button
ref={toggleBtnRef}
onClick={toggleMenu}
className="sm-toggle pointer-events-auto flex items-center gap-3 px-6 py-3 rounded-full bg-transparent hover:bg-foreground/5 transition-colors focus:outline-none"
aria-expanded={open}
>
<div className="relative h-[1.2em] overflow-hidden min-w-[50px] text-left">
<div ref={textInnerRef} className="flex flex-col font-medium uppercase tracking-wider text-sm">
{textLines.map((line, i) => (
<span key={i} className="h-[1.2em] leading-tight flex items-center">{line}</span>
))}
</div>
</div>
<div ref={iconRef} className="relative w-4 h-4">
<span ref={plusHRef} className="absolute top-1/2 left-0 w-full h-0.5 bg-current rounded-full -translate-y-1/2" />
<span ref={plusVRef} className="absolute top-0 left-1/2 w-0.5 h-full bg-current rounded-full -translate-x-1/2" />
</div>
</button>
</header>
{/* Menu Panel */}
<aside
ref={panelRef}
className={cn(
"staggered-menu-panel absolute top-0 bottom-0 z-10 pointer-events-auto flex flex-col pt-32 pb-12 px-8 sm:px-16 overflow-y-auto w-[100vw] sm:w-[50vw] md:w-[40vw] lg:w-[30vw]",
position === 'left' ? 'left-0' : 'right-0'
)}
style={{ background: colors[colors.length - 1] }}
>
<div className="flex-1 flex flex-col">
<nav>
<ul className="flex flex-col gap-4 list-none p-0 m-0">
{items.map((item, idx) => (
<li key={idx} className="overflow-hidden">
<a
href={item.link}
className="group relative flex items-baseline gap-4 no-underline"
aria-label={item.ariaLabel}
>
{displayItemNumbering && (
<span className="text-sm font-medium opacity-40 translate-y-[-0.5rem]">
{(idx + 1).toString().padStart(2, '0')}
</span>
)}
<span className="sm-panel-itemLabel inline-block font-bold text-4xl sm:text-5xl md:text-6xl text-black uppercase tracking-tighter transition-colors group-hover:text-[var(--sm-accent)]">
{item.label}
</span>
</a>
</li>
))}
</ul>
</nav>
{displaySocials && socialItems.length > 0 && (
<div className="mt-auto pt-12">
<h3 className="sm-socials-title text-xs font-bold uppercase tracking-widest mb-6 opacity-40">Socials</h3>
<ul className="flex flex-wrap gap-x-8 gap-y-2 list-none p-0 m-0">
{socialItems.map((social, i) => (
<li key={i}>
<a
href={social.link}
target="_blank"
rel="noopener noreferrer"
className="sm-socials-link text-sm font-medium text-black no-underline hover:text-[var(--sm-accent)] transition-colors py-1 inline-block"
>
{social.label}
</a>
</li>
))}
</ul>
</div>
)}
</div>
</aside>
</div>
</div>
);
};
export default StaggeredMenu;
~~~
Implementation Guidelines
1. Analyze the component structure, styling, animation implementations
2. Review the component's arguments and state
3. Think through what is the best place to adopt this component/style into the design we are doing
4. Then adopt the component/design to our current system
Help me integrate this into my design