Back to Resources
INTERACTIVE 3D LANDING PAGE

Build a Nike-Style 3D Website

Copy this complete prompt to instantly generate a high-end interactive landing page with GSAP animations, spotlight masks, and a bubble menu — just like Nike's flagship pages.

Paste this into Claude, v0, or Cursor to build instantly

Click the link for 3D assets

GSAP Animations

Bubble menu with smooth scale and stagger transitions

Spotlight Mask

Interactive cursor-following video reveal effect

Tailwind v4

Modern font stack with Manrope + Instrument Serif

Create a high-end, interactive Nike hero landing page with two scrolling sections. The app requires `react-player` and `gsap` for animations and interactive masks.

Follow these strict requirements to perfectly match the design, assets, fonts, and logic:

### 1. Dependencies to Install
Install `react-player` and `gsap`.

### 2. Globals & Configuration (`src/index.css`)
Replace `index.css` with this exact Tailwind v4 and Google Fonts configuration:
```css
@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Manrope:wght@400;500;600;700&display=swap');
@import "tailwindcss";

@theme {
  --font-sans: "Manrope", sans-serif;
  --font-serif: "Instrument Serif", serif;
}
```

### 3. Bubble Menu Component (`src/components/BubbleMenu.css`)
```css
.bubble-menu { display: flex; align-items: center; gap: 12px; z-index: 50; }
.bubble-menu.absolute { position: absolute; }
.bubble-menu.fixed { position: fixed; }
.bubble { width: 50px; height: 50px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(8px); transition: transform 0.2s ease, background 0.3s ease; }
.bubble:hover { transform: scale(1.05); }
.menu-btn { flex-direction: column; gap: 6px; }
.menu-line { width: 20px; height: 2px; border-radius: 2px; transition: all 0.3s ease; }
.menu-line.short { width: 14px; }
.menu-btn:hover .menu-line.short { width: 20px; }
.menu-btn.open .menu-line:not(.short) { transform: translateY(4px) rotate(45deg); }
.menu-btn.open .menu-line.short { transform: translateY(-4px) rotate(-45deg); width: 20px; }
.bubble-menu-items { inset: 0; position: fixed; display: none; align-items: center; justify-content: center; z-index: 40; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
.pill-list { list-style: none; padding: 0; margin: 0; display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; max-width: 800px; }
.pill-link { display: block; padding: 16px 36px; border-radius: 9999px; background-color: var(--pill-bg); color: var(--pill-color); text-decoration: none; font-weight: 500; font-size: 24px; transform: rotate(var(--item-rot)); transition: all 0.3s ease; border: 1px solid rgba(255, 255, 255, 0.1); }
.pill-link:hover { background-color: var(--hover-bg) !important; color: var(--hover-color) !important; transform: scale(1.05) rotate(0deg); }
.pill-label { display: block; }
```

### 4. Bubble Menu Logic (`src/components/BubbleMenu.tsx`)
Create a GSAP-animated pill-menu component.
```tsx
import { useState, useRef, useEffect, ReactNode } from 'react';
import { gsap } from 'gsap';
import './BubbleMenu.css';

interface MenuItem { label: string; href: string; ariaLabel?: string; rotation?: number; hoverStyles?: { bgColor: string; textColor: string }; }
interface BubbleMenuProps { logo?: string | ReactNode; onMenuClick?: (isOpen: boolean) => void; className?: string; style?: React.CSSProperties; menuAriaLabel?: string; menuBg?: string; menuContentColor?: string; useFixedPosition?: boolean; items?: MenuItem[]; animationEase?: string; animationDuration?: number; staggerDelay?: number; }

export default function BubbleMenu({ logo, onMenuClick, className, style, menuAriaLabel = 'Toggle menu', menuBg = '#fff', menuContentColor = '#111', useFixedPosition = false, items, animationEase = 'back.out(1.5)', animationDuration = 0.5, staggerDelay = 0.12 }: BubbleMenuProps) {
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const [showOverlay, setShowOverlay] = useState(false);
  const overlayRef = useRef<HTMLDivElement>(null);
  const bubblesRef = useRef<(HTMLAnchorElement | null)[]>([]);
  const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);

  const containerClassName = ['bubble-menu', useFixedPosition ? 'fixed' : 'absolute', className].filter(Boolean).join(' ');

  const handleToggle = () => {
    const nextState = !isMenuOpen;
    if (nextState) setShowOverlay(true);
    setIsMenuOpen(nextState);
    onMenuClick?.(nextState);
  };

  useEffect(() => {
    const overlay = overlayRef.current;
    const bubbles = bubblesRef.current.filter(Boolean);
    const labels = labelRefs.current.filter(Boolean);
    if (!overlay || !bubbles.length) return;

    if (isMenuOpen) {
      gsap.set(overlay, { display: 'flex' });
      gsap.killTweensOf([...bubbles, ...labels]);
      gsap.set(bubbles, { scale: 0, transformOrigin: '50% 50%' });
      gsap.set(labels, { y: 24, autoAlpha: 0 });

      bubbles.forEach((bubble, i) => {
        const delay = i * staggerDelay + gsap.utils.random(-0.05, 0.05);
        const tl = gsap.timeline({ delay });
        tl.to(bubble, { scale: 1, duration: animationDuration, ease: animationEase });
        if (labels[i]) tl.to(labels[i], { y: 0, autoAlpha: 1, duration: animationDuration, ease: 'power3.out' }, `-=${animationDuration * 0.9}`);
      });
    } else if (showOverlay) {
      gsap.killTweensOf([...bubbles, ...labels]);
      gsap.to(labels, { y: 24, autoAlpha: 0, duration: 0.2, ease: 'power3.in' });
      gsap.to(bubbles, { scale: 0, duration: 0.2, ease: 'power3.in', onComplete: () => { gsap.set(overlay, { display: 'none' }); setShowOverlay(false); } });
    }
  }, [isMenuOpen, showOverlay, animationEase, animationDuration, staggerDelay]);

  return (
    <>
      <nav className={containerClassName} style={style} aria-label="Main navigation">
        <button type="button" className={`bubble toggle-bubble menu-btn ${isMenuOpen ? 'open' : ''}`} onClick={handleToggle} style={{ background: menuBg }}>
          <span className="menu-line" style={{ background: menuContentColor }} />
          <span className="menu-line short" style={{ background: menuContentColor }} />
        </button>
      </nav>
      {showOverlay && (
        <div ref={overlayRef} className="bubble-menu-items fixed">
          <ul className="pill-list">
            {items?.map((item, idx) => (
              <li key={idx}>
                <a href={item.href} className="pill-link" style={{ '--item-rot': `${item.rotation ?? 0}deg`, '--pill-bg': menuBg, '--pill-color': menuContentColor, '--hover-bg': item.hoverStyles?.bgColor, '--hover-color': item.hoverStyles?.textColor } as any} ref={el => { bubblesRef.current[idx] = el; }} onClick={handleToggle}>
                  <span className="pill-label" ref={el => { labelRefs.current[idx] = el; }}>{item.label}</span>
                </a>
              </li>
            ))}
          </ul>
        </div>
      )}
    </>
  );
}
```

### 5. Spotlight Reveal Interactive Video Mask (`src/components/SpotlightReveal.tsx`)
```tsx
import { useEffect, useRef } from 'react';
import ReactPlayer from 'react-player';

interface SpotlightRevealProps { imageSrc: string; videoSrc: string; isPlaying?: boolean; baseRadius?: number; }

export default function SpotlightReveal({ imageSrc, videoSrc, isPlaying = true, baseRadius = 420 }: SpotlightRevealProps) {
  const NUM_TRAILS = 6;
  const videoRef = useRef<HTMLVideoElement>(null);
  const pointsRef = useRef(Array.from({ length: NUM_TRAILS }, () => ({ x: -1000, y: -1000 })));

  useEffect(() => {
    if (videoRef.current) { isPlaying ? videoRef.current.play() : videoRef.current.pause(); }
  }, [isPlaying]);

  useEffect(() => {
    let targetX = window.innerWidth / 2, targetY = window.innerHeight / 2;
    const handleMouseMove = (e: MouseEvent) => { targetX = e.clientX; targetY = e.clientY; };
    window.addEventListener('mousemove', handleMouseMove);

    let animationFrameId: number;
    const animate = () => {
      const points = pointsRef.current;
      points[0].x += (targetX - points[0].x) * 0.2;
      points[0].y += (targetY - points[0].y) * 0.2;
      for (let i = 1; i < points.length; i++) {
        points[i].x += (points[i - 1].x - points[i].x) * 0.35;
        points[i].y += (points[i - 1].y - points[i].y) * 0.35;
      }
      for (let i = 0; i < points.length; i++) {
        const circle = document.getElementById(`trail-${i}`);
        if (circle) { circle.setAttribute('cx', points[i].x.toString()); circle.setAttribute('cy', points[i].y.toString()); }
      }
      animationFrameId = requestAnimationFrame(animate);
    };
    animate();
    return () => { window.removeEventListener('mousemove', handleMouseMove); cancelAnimationFrame(animationFrameId); };
  }, []);

  return (
    <div className="absolute inset-0 w-full h-full z-0 bg-black pointer-events-none overflow-hidden flex items-center justify-center">
      <div className="absolute inset-0 w-full h-full flex items-center justify-center overflow-hidden pointer-events-none">
        <video ref={videoRef} src={videoSrc} className="absolute inset-0 w-full h-full object-cover" muted loop playsInline />
      </div>
      <svg className="absolute inset-0 w-full h-full" xmlns="http://www.w3.org/2000/svg">
        <defs>
          <radialGradient id="holeGradient">
            <stop offset="0%" stopColor="black" stopOpacity="1" />
            <stop offset="60%" stopColor="black" stopOpacity="0.8" />
            <stop offset="100%" stopColor="black" stopOpacity="0" />
          </radialGradient>
          <mask id="spotlight-mask" maskContentUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
            <rect width="100%" height="100%" fill="white" />
            {Array.from({ length: NUM_TRAILS }).reverse().map((_, reversedIndex) => {
              const i = NUM_TRAILS - 1 - reversedIndex;
              return <circle key={`trail-${i}`} id={`trail-${i}`} cx="-1000" cy="-1000" r={baseRadius - i * 35} fill="url(#holeGradient)" opacity={1 - i * 0.15} />;
            })}
          </mask>
        </defs>
        <image href={imageSrc} width="100%" height="100%" preserveAspectRatio="xMidYMid slice" mask="url(#spotlight-mask)" />
      </svg>
    </div>
  );
}
```

### 6. App Layout & Data (`src/App.tsx`)
```tsx
import { useState } from 'react';
import BubbleMenu from './components/BubbleMenu';
import SpotlightReveal from './components/SpotlightReveal';

const items = [
  { label: 'Drops', href: '#', rotation: -8, hoverStyles: { bgColor: '#ef4444', textColor: '#ffffff' } },
  { label: 'Innovation', href: '#', rotation: 8, hoverStyles: { bgColor: '#3b82f6', textColor: '#ffffff' } },
  { label: 'Collections', href: '#', rotation: 8, hoverStyles: { bgColor: '#10b981', textColor: '#ffffff' } },
  { label: 'Community', href: '#', rotation: 8, hoverStyles: { bgColor: '#f59e0b', textColor: '#ffffff' } },
  { label: 'Stores', href: '#', rotation: -8, hoverStyles: { bgColor: '#8b5cf6', textColor: '#ffffff' } }
];

export default function App() {
  const [isFirstVideoPlaying, setIsFirstVideoPlaying] = useState(false);
  const [isSecondVideoPlaying, setIsSecondVideoPlaying] = useState(false);

  return (
    <div className="relative w-full flex flex-col bg-[#050505]">
      {/* First Screen */}
      <section className="sticky top-0 z-0 w-full h-[100dvh] overflow-hidden flex flex-col justify-between pointer-events-auto">
        <SpotlightReveal 
          imageSrc="https://github.com/dsMagnatov/Acreage-landing-assets/blob/main/0098888.jpg?raw=true"
          videoSrc="https://pikaso.cdnpk.net/private/production/4021778466/80a7f7ef-643d-40bc-b533-1e86f159d653-0.mp4?token=exp=1777075200~hmac=91d86c3600a89e923130fce0912dcfb0de81f05f2cde5fc77c30f3e7ae094342"
          isPlaying={isFirstVideoPlaying}
        />
        
        <div className="absolute bottom-0 left-0 w-full h-[75%] z-20" onMouseEnter={() => setIsFirstVideoPlaying(true)} onMouseLeave={() => setIsFirstVideoPlaying(false)} />
        
        <header className="relative z-50 w-full flex justify-center items-start pt-[150px]">
          <svg width="120" viewBox="135.5 361.38 420.32 149.8" fill="white" xmlns="http://www.w3.org/2000/svg">
            <path d="m181.86 511.11c-12.524-0.49755-22.77-3.9244-30.782-10.289-1.529-1.2159-5.1725-4.8616-6.3949-6.3992-3.2489-4.0853-5.4578-8.0611-6.931-12.472-4.5334-13.579-2.2002-31.397 6.6737-50.953 7.5979-16.742 19.322-33.347 39.776-56.344 3.013-3.384 11.986-13.281 12.043-13.281 0.0216 0-0.46749 0.84706-1.083 1.8786-5.3183 8.9082-9.8689 19.401-12.348 28.485-3.9823 14.576-3.502 27.085 1.4068 36.784 3.3862 6.6822 9.1913 12.47 15.719 15.67 11.428 5.5993 28.159 6.0625 48.592 1.3554 1.4068-0.32599 71.116-18.831 154.91-41.123 83.794-22.294 152.36-40.52 152.37-40.505 0.0237 0.0193-194.68 83.333-295.75 126.56-16.007 6.8431-20.287 8.5715-27.812 11.214-19.236 6.7551-36.467 9.9783-50.396 9.4251z"/>
          </svg>
          <BubbleMenu items={items} className="absolute top-8 right-8 z-50" />
        </header>

        <main className="relative z-10 w-full flex-1 flex flex-col items-center justify-end pb-24 px-4 text-center text-white">
          <h1 className="font-sans font-medium leading-[1.05] tracking-tight w-full mx-auto translate-y-[50px]" style={{ fontSize: 'clamp(14px, 3vw, 51px)' }}>
            <span className="block">Pure Comfort For</span>
            <span className="block">Next-Generation Athletes. <span className="font-serif italic font-normal pr-1">We Craft</span></span>
            <span className="block font-serif italic font-normal pr-1">The Ultimate Footwear For Elite Performance,</span>
            <span className="block font-serif italic font-normal pr-1">Urban Exploration, Everyday Style.</span>
          </h1>
        </main>
      </section>

      {/* Second Screen */}
      <section className="relative z-10 w-full h-[100dvh] overflow-hidden bg-black text-white" style={{ boxShadow: '0 -20px 50px rgba(0,0,0,0.5)' }}>
        <SpotlightReveal 
          imageSrc="https://github.com/dsMagnatov/Acreage-landing-assets/blob/main/02604201313.png?raw=true"
          videoSrc="https://pikaso.cdnpk.net/private/production/4024859125/d070ae9c-55df-47aa-acbe-4ee66337855c-0.mp4?token=exp=1777075200~hmac=4202c1d0ec90137eb6dffa8e0db93ed7569a68b2016165d8b1b567f888869ff5"
          isPlaying={isSecondVideoPlaying}
          baseRadius={520}
        />

        <div className="absolute right-[calc(8%+100px)] bottom-[12%] w-[calc(50%-50px)] h-[calc(50%+230px)] z-30" onMouseEnter={() => setIsSecondVideoPlaying(true)} onMouseLeave={() => setIsSecondVideoPlaying(false)} />
        <div className="absolute left-[calc(8%+200px)] top-[calc(20%+190px)] w-[calc(15%+250px)] h-[calc(22.5%+130px)] -translate-y-full z-30" onMouseEnter={() => setIsSecondVideoPlaying(true)} onMouseLeave={() => setIsSecondVideoPlaying(false)} />

        <div className="absolute left-[calc(8%+200px)] top-[20%] z-20 w-[320px] px-8 py-6 rounded-sm border border-white/10" style={{ background: 'rgba(0, 0, 0, 0.16)', backdropFilter: 'blur(80px)', WebkitBackdropFilter: 'blur(80px)' }}>
          <div className="flex items-end gap-2 mb-4">
            <span className="font-serif italic text-[#DA3A16] text-[72px] leading-[80px] tracking-tight">78%</span>
          </div>
          <h3 className="font-serif text-white text-[15px] tracking-[0.02em] uppercase mb-2 leading-tight">NEXT-GEN CUSHIONING ARCHITECTURE</h3>
          <p className="font-serif text-white/64 text-[13px]">Impact Absorption & Energy Return Dynamics</p>
        </div>

        <div className="absolute left-[8%] bottom-[12%] z-20 text-white max-w-[500px]">
          <h2 className="text-[44px] leading-[1.05] tracking-tight flex flex-col">
            <span className="font-sans font-medium">Bringing Aerospace-</span>
            <span className="font-sans font-medium">Grade Infrastructure</span>
            <span className="font-serif font-normal pt-1"><span className="not-italic">Directly To Your </span><span className="italic">Everyday</span></span>
            <span className="font-serif italic font-normal">Urban Exploration</span>
          </h2>
        </div>
      </section>
    </div>
  );
}
```

Ready to Build?

Copy the full prompt and paste it into Claude or any vibe coding tool to generate the entire experience in one shot.