VibeCoderzVibeCoderz
Telegram
All 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
All Prompts