Staggered Menu

Build an animated fullscreen navigation menu with staggered animations from scratch

Preview
Source Code

Overview

The Staggered Menu is a premium navigation component that creates a stunning fullscreen overlay with smooth animations. This component features:

  • Smooth slide animations with GSAP-powered transitions
  • Staggered item reveals for a premium feel
  • Background layers for depth and visual interest
  • Animated hamburger icon with text cycling
  • Social links section with hover effects
  • Full accessibility support with ARIA labels
  • Responsive design that works on all devices

What You'll Learn

This tutorial covers advanced React patterns, GSAP animation techniques, and modern CSS features to create a truly exceptional navigation experience.

Installation

First, let's set up a new Next.js project and install the required dependencies:

npx create-next-app@latest staggered-menu-app
cd staggered-menu-app

Install the required dependencies:

npm install gsap
npm install -D tailwindcss postcss autoprefixer

Dependencies Required

Make sure you have Node.js 18+ installed before proceeding with the installation.

Clean up the default files and prepare for our custom implementation:

# Clear default content from these files
echo "" > src/app/page.js
echo "" > src/app/globals.css

Step 1: Component Structure & TypeScript Interfaces

Let's start by creating the foundational structure with proper TypeScript interfaces and React hooks. This establishes a solid base for our animated menu component:

Component Structure
'use client'

import { gsap } from 'gsap'
import Image from 'next/image'
import React, { useRef, useState } from 'react'
import './staggered-menu.css'

export interface StaggeredMenuItem {
  label: string
  ariaLabel: string
  link: string
}

export interface StaggeredMenuProps {
  position?: 'left' | 'right'
  items?: StaggeredMenuItem[]
  zIndex?: number
}

export const StaggeredMenu: React.FC<StaggeredMenuProps> = ({
  position = 'right',
  items = [],
  zIndex = 5,
}) => {
  const [open, setOpen] = useState(false)
  const toggleBtnRef = useRef<HTMLButtonElement | null>(null)
  const panelRef = useRef<HTMLDivElement | null>(null)

  return (
    <div className="staggered-menu-wrapper" style={{ zIndex }}>
      {/* Menu components will go here */}
    </div>
  )
}
Base Styles
.staggered-menu-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
  z-index: 4;
}

Key implementation details:

  • Client-side rendering: Using 'use client' directive for Next.js App Router
  • Type safety: TypeScript interfaces ensure proper prop validation
  • DOM references: React refs for direct GSAP animation control
  • State management: React hooks for menu open/close state
  • Modular design: Clean separation of concerns with dedicated CSS file

Best Practice

Always use TypeScript interfaces for component props to ensure type safety and better developer experience.

Step 2: Animated Toggle Button

Now we'll create the interactive hamburger menu button with animated text and icon elements. This button will serve as the primary trigger for our menu animations:

export const StaggeredMenu: React.FC<StaggeredMenuProps> = ({
  position = 'right',
  items = [],
  zIndex = 5,
}) => {
  const [open, setOpen] = useState(false)
  const toggleBtnRef = useRef<HTMLButtonElement | null>(null)
  const panelRef = useRef<HTMLDivElement | null>(null)
  
  // Icon refs for GSAP animations
  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 toggleMenu = () => {
    setOpen(!open)
    // Animation logic will go here
  }

  return (
    <div className="staggered-menu-wrapper" style={{ zIndex }}>
      {/* Header with toggle button */}
      <header className="staggered-menu-header">
        <div className="sm-logo">
          <Image
            src="/logo.svg"
            alt="Logo"
            className="sm-logo-img"
            width={110}
            height={24}
            priority
          />
        </div>

        <button
          ref={toggleBtnRef}
          className="sm-toggle"
          aria-label={open ? 'Close menu' : 'Open menu'}
          aria-expanded={open}
          onClick={toggleMenu}
          type="button"
        >
          <span className="sm-toggle-textWrap">
            <span ref={textInnerRef} className="sm-toggle-textInner">
              {textLines.map((line, i) => (
                <span className="sm-toggle-line" key={i}>
                  {line}
                </span>
              ))}
            </span>
          </span>

          <span ref={iconRef} className="sm-icon">
            <span ref={plusHRef} className="sm-icon-line" />
            <span ref={plusVRef} className="sm-icon-line sm-icon-line-v" />
          </span>
        </button>
      </header>
    </div>
  )
}

Toggle button features:

  • Interactive design: Clean hamburger button with text and icon elements
  • Icon structure: Two transformable lines for smooth hamburger-to-X animation
  • Accessibility: Complete ARIA support with proper labels and states
  • Event handling: Smart pointer events management for optimal UX
  • Visual feedback: Focus states and hover effects for better interaction

Accessibility Note

The button includes proper ARIA attributes (aria-label, aria-expanded) to ensure screen readers can understand the menu state and purpose.

Visual result:

Step 3: Fullscreen Menu Panel

Now we'll build the main menu panel with dramatic typography and modern glassmorphism effects. This panel will slide in smoothly from the side when activated:

export const StaggeredMenu: React.FC<StaggeredMenuProps> = ({
  position = 'right',
  items = [],
  zIndex = 5,
}) => {
  // ... existing code ...

  return (
    <div className="staggered-menu-wrapper" style={{ zIndex }}>
      {/* Header with toggle button */}
      <header className="staggered-menu-header">
        {/* ... existing header code ... */}
      </header>

      {/* Menu Panel */}
      <aside
        ref={panelRef}
        className="staggered-menu-panel"
        aria-hidden={!open}
      >
        <div className="sm-panel-inner">
          <ul className="sm-panel-list">
            {items?.length ? (
              items.map((item, idx) => (
                <li className="sm-panel-itemWrap" key={item.label + idx}>
                  <Link
                    className="sm-panel-item"
                    href={item.link}
                    aria-label={item.ariaLabel}
                  >
                    <span className="sm-panel-itemLabel">
                      {item.label}
                    </span>
                  </Link>
                </li>
              ))
            ) : (
              <li className="sm-panel-itemWrap">
                <span className="sm-panel-item">
                  <span className="sm-panel-itemLabel">
                    No items
                  </span>
                </span>
              </li>
            )}
          </ul>
        </div>
      </aside>
    </div>
  )
}

Panel design features:

  • Fullscreen overlay: Complete viewport coverage with smooth transitions
  • Glassmorphism effect: Modern backdrop blur with transparency
  • Dramatic typography: Large 3.5rem font size for visual impact
  • Animation-ready structure: Each item individually wrapped for staggered effects
  • Responsive design: Flexible width using CSS clamp() for all screen sizes
  • Semantic HTML: Proper use of <aside> and <ul> elements

CSS Tip

The clamp(260px, 38vw, 420px) function creates a responsive width that scales between 260px and 420px based on viewport width, ensuring optimal display on all devices.

Visual result:

Step 4: GSAP Animation System

Now we'll implement the core animation system using GSAP. This creates smooth slide-in effects and staggered item reveals that give the menu its premium feel:

import { gsap } from 'gsap'
import Image from 'next/image'
import React, { useRef, useState, useLayoutEffect, useCallback } from 'react'
import Link from 'next/link'

export const StaggeredMenu: React.FC<StaggeredMenuProps> = ({
  position = 'right',
  items = [],
  zIndex = 5,
}) => {
  const [open, setOpen] = useState(false)
  const openRef = useRef(false)
  
  // Refs
  const toggleBtnRef = useRef<HTMLButtonElement | null>(null)
  const panelRef = useRef<HTMLDivElement | null>(null)
  const plusHRef = useRef<HTMLSpanElement | null>(null)
  const plusVRef = useRef<HTMLSpanElement | null>(null)
  const iconRef = useRef<HTMLSpanElement | null>(null)

  // Animation refs
  const openTlRef = useRef<gsap.core.Timeline | null>(null)
  const closeTweenRef = useRef<gsap.core.Tween | null>(null)

  // Initialize GSAP setup
  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      const panel = panelRef.current
      const plusH = plusHRef.current
      const plusV = plusVRef.current
      const icon = iconRef.current

      if (!(panel && plusH && plusV && icon)) return

      // Set initial positions - panel starts offscreen
      const offscreen = position === 'left' ? -100 : 100
      gsap.set(panel, { xPercent: offscreen })
      
      // Set icon initial states
      gsap.set(plusH, { transformOrigin: '50% 50%', rotate: 0 })
      gsap.set(plusV, { transformOrigin: '50% 50%', rotate: 90 })
      gsap.set(icon, { rotate: 0, transformOrigin: '50% 50%' })
    })
    return () => ctx.revert()
  }, [position])

  // Build open animation timeline
  const buildOpenTimeline = useCallback(() => {
    const panel = panelRef.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[]

    // Set initial item positions - items start rotated and moved down
    if (itemEls.length) gsap.set(itemEls, { yPercent: 140, rotate: 10 })

    const tl = gsap.timeline({ paused: true })

    // Panel slide in from offscreen
    tl.fromTo(
      panel,
      { xPercent: position === 'left' ? -100 : 100 },
      { xPercent: 0, duration: 0.65, ease: 'power4.out' },
      0
    )

    // Items stagger in after panel starts moving
    if (itemEls.length) {
      tl.to(
        itemEls,
        {
          yPercent: 0,
          rotate: 0,
          duration: 1,
          ease: 'power4.out',
          stagger: { each: 0.1, from: 'start' },
        },
        0.2
      )
    }

    openTlRef.current = tl
    return tl
  }, [position])

  // Play open animation
  const playOpen = useCallback(() => {
    const tl = buildOpenTimeline()
    if (tl) {
      tl.play(0)
    }
  }, [buildOpenTimeline])

  // Play close animation
  const playClose = useCallback(() => {
    const panel = panelRef.current
    if (!panel) return

    openTlRef.current?.kill()
    openTlRef.current = null

    const offscreen = position === 'left' ? -100 : 100

    closeTweenRef.current = gsap.to(panel, {
      xPercent: offscreen,
      duration: 0.32,
      ease: 'power3.in',
      onComplete: () => {
        // Reset item positions for next open
        const itemEls = Array.from(
          panel.querySelectorAll('.sm-panel-itemLabel')
        ) as HTMLElement[]
        if (itemEls.length) gsap.set(itemEls, { yPercent: 140, rotate: 10 })
      },
    })
  }, [position])

  const toggleMenu = useCallback(() => {
    const target = !openRef.current
    openRef.current = target
    setOpen(target)

    if (target) {
      playOpen()
    } else {
      playClose()
    }
  }, [playOpen, playClose])

  // ... rest of component JSX
}

Animation system features:

  • Initial positioning: Panel starts offscreen, items pre-positioned for smooth entry
  • Timeline management: Complex GSAP timelines for coordinated animations
  • Staggered reveals: Items animate in sequence for premium feel
  • Performance optimization: Proper cleanup and memory management
  • Smooth transitions: Power4 easing for natural motion
  • State synchronization: Animation state properly synced with React state

Performance Tip

Always use gsap.context() to properly clean up animations and prevent memory leaks. This is especially important in React components that may unmount.

Visual result:

Step 5: Background Layers & Depth

To create visual depth and sophistication, we'll add animated background layers that slide in before the main panel. This creates the layered effect commonly seen in award-winning websites:

export interface StaggeredMenuProps {
  position?: 'left' | 'right'
  items?: StaggeredMenuItem[]
  colors?: string[]
  zIndex?: number
}

export const StaggeredMenu: React.FC<StaggeredMenuProps> = ({
  position = 'right',
  items = [],
  colors = ['#3B82F6', '#1E40AF'],
  zIndex = 5,
}) => {
  // ... existing code ...

  const preLayersRef = useRef<HTMLDivElement | null>(null)
  const preLayerElsRef = useRef<HTMLElement[]>([])

  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      const panel = panelRef.current
      const preContainer = preLayersRef.current

      if (!(panel && preContainer)) return

      let preLayers: HTMLElement[] = []
      if (preContainer) {
        preLayers = Array.from(
          preContainer.querySelectorAll('.sm-prelayer')
        ) as HTMLElement[]
      }
      preLayerElsRef.current = preLayers

      // Set initial positions - both panel and layers start offscreen
      const offscreen = position === 'left' ? -100 : 100
      gsap.set([panel, ...preLayers], { xPercent: offscreen })
    })
    return () => ctx.revert()
  }, [position])

  // Update buildOpenTimeline to include layers
  const buildOpenTimeline = useCallback(() => {
    const panel = panelRef.current
    const layers = preLayerElsRef.current
    if (!panel) return null

    // ... existing code ...

    const tl = gsap.timeline({ paused: true })

    // Animate layers with stagger - each layer slides in slightly after the previous
    layers.forEach((layer, i) => {
      tl.fromTo(
        layer,
        { xPercent: position === 'left' ? -100 : 100 },
        { xPercent: 0, duration: 0.5, ease: 'power4.out' },
        i * 0.07
      )
    })

    // Panel animation - starts after the last layer
    const lastTime = layers.length ? (layers.length - 1) * 0.07 : 0
    tl.fromTo(
      panel,
      { xPercent: position === 'left' ? -100 : 100 },
      { xPercent: 0, duration: 0.65, ease: 'power4.out' },
      lastTime + 0.08
    )

    // ... rest of timeline
  }, [position])

  return (
    <div className="staggered-menu-wrapper" style={{ zIndex }}>
      {/* Background Layers */}
      <div
        ref={preLayersRef}
        className="sm-prelayers"
        aria-hidden="true"
      >
        {(() => {
          const raw = colors && colors.length ? colors.slice(0, 4) : ['#F3F4F6', '#E5E7EB']
          const arr = [...raw]
          if (arr.length >= 3) {
            const mid = Math.floor(arr.length / 2)
            arr.splice(mid, 1)
          }
          return arr.map((color, i) => (
            <div
              key={i}
              className="sm-prelayer"
              style={{ background: color }}
            />
          ))
        })()}
      </div>

      {/* ... rest of component */}
    </div>
  )
}

Background layers features:

  • Multi-layered depth: Creates sophisticated visual hierarchy
  • Custom color system: Flexible color array for brand customization
  • Intelligent spacing: Automatic layer count optimization for smooth animations
  • Performance optimized: Minimal DOM elements with maximum visual impact
  • Responsive design: Layers adapt to different screen sizes

Design Insight

The layered approach creates a sense of depth and sophistication that's commonly used in award-winning websites. Each layer slides in with a slight delay, creating a cascading effect.

Visual result:

Step 6: Icon Animation & Text Effects

Finally, we'll add the sophisticated hamburger icon transformation and text cycling effects to complete our premium menu experience:

export interface StaggeredMenuSocialItem {
  label: string
  link: string
}

export interface StaggeredMenuProps {
  position?: 'left' | 'right'
  items?: StaggeredMenuItem[]
  socialItems?: StaggeredMenuSocialItem[]
  displaySocials?: boolean
  displayItemNumbering?: boolean
  colors?: string[]
  zIndex?: number
}

export const StaggeredMenu: React.FC<StaggeredMenuProps> = ({
  position = 'right',
  items = [],
  socialItems = [],
  displaySocials = true,
  displayItemNumbering = true,
  colors = ['#3B82F6', '#1E40AF'],
  zIndex = 5,
}) => {
  // ... existing code ...
  
  const textInnerRef = useRef<HTMLSpanElement | null>(null)
  const [textLines, setTextLines] = useState<string[]>(['Menu', 'Close'])

  // Animate icon transformation
  const animateIcon = useCallback((opening: boolean) => {
    const icon = iconRef.current
    const h = plusHRef.current
    const v = plusVRef.current
    if (!(icon && h && v)) return

    if (opening) {
      gsap.set(icon, { rotate: 0, transformOrigin: '50% 50%' })
      gsap.timeline({ defaults: { ease: 'power4.out' } })
        .to(h, { rotate: 45, duration: 0.5 }, 0)
        .to(v, { rotate: -45, duration: 0.5 }, 0)
    } else {
      gsap.timeline({ defaults: { ease: 'power3.inOut' } })
        .to(h, { rotate: 0, duration: 0.35 }, 0)
        .to(v, { rotate: 90, duration: 0.35 }, 0)
    }
  }, [])

  // Animate text cycling
  const animateText = useCallback((opening: boolean) => {
    const inner = textInnerRef.current
    if (!inner) return

    const currentLabel = opening ? 'Menu' : 'Close'
    const targetLabel = opening ? 'Close' : 'Menu'
    const cycles = 3
    const seq: string[] = [currentLabel]
    let last = currentLabel
    for (let i = 0; i < cycles; i++) {
      last = last === 'Menu' ? 'Close' : 'Menu'
      seq.push(last)
    }
    if (last !== targetLabel) seq.push(targetLabel)
    seq.push(targetLabel)
    setTextLines(seq)

    gsap.set(inner, { yPercent: 0 })
    const lineCount = seq.length
    const finalShift = ((lineCount - 1) / lineCount) * 100
    gsap.to(inner, {
      yPercent: -finalShift,
      duration: 0.5 + lineCount * 0.07,
      ease: 'power4.out',
    })
  }, [])

  const toggleMenu = useCallback(() => {
    const target = !openRef.current
    openRef.current = target
    setOpen(target)

    if (target) {
      playOpen()
    } else {
      playClose()
    }

    animateIcon(target)
    animateText(target)
  }, [playOpen, playClose, animateIcon, animateText])

  return (
    <div className="staggered-menu-wrapper" style={{ zIndex }}>
      {/* ... existing layers and header ... */}

      <aside
        ref={panelRef}
        className="staggered-menu-panel"
        aria-hidden={!open}
      >
        <div className="sm-panel-inner">
          <ul
            className="sm-panel-list"
            data-numbering={displayItemNumbering || undefined}
          >
            {items?.length ? (
              items.map((item, idx) => (
                <li className="sm-panel-itemWrap" key={item.label + idx}>
                  <Link
                    className="sm-panel-item"
                    href={item.link}
                    aria-label={item.ariaLabel}
                  >
                    <span className="sm-panel-itemLabel">
                      {item.label}
                    </span>
                  </Link>
                </li>
              ))
            ) : (
              <li className="sm-panel-itemWrap">
                <span className="sm-panel-item">
                  <span className="sm-panel-itemLabel">
                    No items
                  </span>
                </span>
              </li>
            )}
          </ul>

          {/* Social Links */}
          {displaySocials && socialItems && socialItems.length > 0 && (
            <div className="sm-socials">
              <h3 className="sm-socials-title">
                Socials
              </h3>
              <ul className="sm-socials-list">
                {socialItems.map((social, i) => (
                  <li key={social.label + i} className="sm-socials-item">
                    <Link
                      href={social.link}
                      target="_blank"
                      rel="noopener noreferrer"
                      className="sm-socials-link"
                    >
                      {social.label}
                    </Link>
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      </aside>
    </div>
  )
}

Usage Example

Here's how to implement the Staggered Menu in your application:

Ready to Use

The component is now complete and ready for production use. Copy the code examples above to get started!

import { StaggeredMenu } from './components/StaggeredMenu'

const menuItems = [
  { label: 'Home', ariaLabel: 'Navigate to home page', link: '/' },
  { label: 'About', ariaLabel: 'Learn more about us', link: '/about' },
  { label: 'Services', ariaLabel: 'View our services', link: '/services' },
  { label: 'Contact', ariaLabel: 'Get in touch', link: '/contact' },
]

const socialItems = [
  { label: 'Twitter', link: 'https://twitter.com' },
  { label: 'GitHub', link: 'https://github.com' },
  { label: 'LinkedIn', link: 'https://linkedin.com' },
]

export default function App() {
  return (
    <StaggeredMenu
      position="right"
      items={menuItems}
      socialItems={socialItems}
      colors={['#3B82F6', '#1E40AF', '#1E3A8A']}
      displaySocials={true}
      displayItemNumbering={true}
      zIndex={10}
    />
  )
}

Customization

The component is highly customizable through props and CSS custom properties:

Custom CSS Variables
:root {
  --sm-accent: #5227ff;
  --sm-num-opacity: 0.6;
  --sm-toggle-width: 80px;
}

Customization Options

You can customize colors, spacing, animations, and more through the component props and CSS custom properties. This makes the component highly flexible for different design systems.

Performance Considerations

  • GSAP Context: Proper cleanup prevents memory leaks
  • Will-change: Optimized for smooth animations
  • Transform-based: Uses GPU acceleration for better performance
  • Minimal re-renders: Efficient state management

Browser Support

  • Modern browsers: Full support with all animations
  • Fallbacks: Graceful degradation for older browsers
  • Mobile optimized: Touch-friendly interactions

Conclusion

The Staggered Menu component provides a premium navigation experience that elevates any website. With its smooth animations, accessibility features, and customizable design, it's perfect for modern web applications.

Key achievements:

  • Premium animations: GSAP-powered smooth transitions
  • Accessibility first: Complete ARIA support and keyboard navigation
  • Performance optimized: Efficient rendering and memory management
  • Highly customizable: Flexible props and CSS variables
  • Mobile responsive: Works seamlessly across all devices
  • Modern design: Glassmorphism and contemporary UI patterns

This component demonstrates advanced React patterns, GSAP animation techniques, and modern CSS features to create a truly exceptional user experience.

Props Reference

Prop

Type


On this page