skills/website/phases/02-hero.md

5.4 KiB

Phase 2 — Cinematic Hero Animation

Generate the hero section using GSAP + ScrollTrigger. Uses the brand data from Phase 1.

Dependencies

npm install gsap @gsap/react lenis

Register plugins once at app root (in layout.tsx or a client provider):

'use client'
import { useEffect } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

export function GSAPProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    gsap.registerPlugin(ScrollTrigger)
  }, [])
  return <>{children}</>
}

Wrap <body> with <GSAPProvider> in layout.tsx.

Animation Sequence

Execute these in order inside useGSAP(() => { ... }, { scope: containerRef }):

1. Hero Headline — Word Stagger Reveal

// Split headline into word spans before rendering
// In JSX: <h1>{headline.split(' ').map((w, i) => <span key={i} className="word inline-block overflow-hidden"><span className="word-inner">{w}&nbsp;</span></span>)}</h1>

gsap.from('.word-inner', {
  y: 60,
  opacity: 0,
  duration: 0.8,
  stagger: 0.08,
  ease: 'power4.out',
})

2. Subheadline — Fade Up

gsap.from('.hero-sub', {
  y: 30,
  opacity: 0,
  duration: 0.7,
  delay: 0.4,
  ease: 'power3.out',
})

3. CTA Button — Spring Entrance

gsap.from('.hero-cta', {
  scale: 0.9,
  opacity: 0,
  duration: 0.6,
  delay: 0.7,
  ease: 'back.out(1.4)',
})

4. Background — Ken Burns (infinite)

gsap.to('.hero-bg', {
  scale: 1.06,
  duration: 8,
  ease: 'none',
  repeat: -1,
  yoyo: true,
})

5. ScrollTrigger — Pin + Parallax Exit

ScrollTrigger.create({
  trigger: containerRef.current,
  start: 'top top',
  end: '+=100%',
  pin: true,
  scrub: 0.5,
  onUpdate: (self) => {
    gsap.set('.hero-content', { y: self.progress * -80, opacity: 1 - self.progress * 1.5 })
    gsap.set('.hero-bg', { scale: 1 + self.progress * 0.06 })
  },
})

Magnetic CTA Button

The CTA uses Framer Motion useMotionValue for magnetic pull. Install: npm install framer-motion

'use client'
import { useRef } from 'react'
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'

export function MagneticButton({ children, href }: { children: React.ReactNode; href: string }) {
  const ref = useRef<HTMLAnchorElement>(null)
  const x = useMotionValue(0)
  const y = useMotionValue(0)
  const springX = useSpring(x, { stiffness: 150, damping: 15 })
  const springY = useSpring(y, { stiffness: 150, damping: 15 })

  const handleMouseMove = (e: React.MouseEvent) => {
    const rect = ref.current!.getBoundingClientRect()
    const cx = rect.left + rect.width / 2
    const cy = rect.top + rect.height / 2
    x.set((e.clientX - cx) * 0.3)
    y.set((e.clientY - cy) * 0.3)
  }

  const handleMouseLeave = () => {
    x.set(0)
    y.set(0)
  }

  return (
    <motion.a
      ref={ref}
      href={href}
      style={{ x: springX, y: springY }}
      onMouseMove={handleMouseMove}
      onMouseLeave={handleMouseLeave}
      whileTap={{ scale: 0.97 }}
      className="hero-cta inline-flex items-center gap-3 rounded-full bg-[var(--color-primary)] px-7 py-4 text-sm font-medium text-white transition-colors duration-300 hover:bg-[var(--color-primary)]/90"
    >
      {children}
      <span className="flex h-7 w-7 items-center justify-center rounded-full bg-white/15">
        
      </span>
    </motion.a>
  )
}

Layout: Asymmetric Split

Always: text left (55%), visual right (45%). Never centered.

<section className="relative grid min-h-[100dvh] grid-cols-1 overflow-hidden lg:grid-cols-[55fr_45fr]">
  {/* Left: Text content */}
  <div className="hero-content flex flex-col justify-center px-8 py-24 lg:px-16 lg:py-32">
    {/* Eyebrow tag */}
    <span className="mb-6 inline-flex w-max items-center rounded-full border border-[var(--color-primary)]/20 bg-[var(--color-primary)]/8 px-3 py-1 text-[10px] font-medium uppercase tracking-[0.2em] text-[var(--color-primary)]">
      {brand.industry}
    </span>
    {/* Headline with word spans */}
    <h1 className="mb-6 text-5xl font-black leading-[0.95] tracking-tight lg:text-7xl">
      {brand.heroHeadline.split(' ').map((word, i) => (
        <span key={i} className="word mr-[0.25em] inline-block overflow-hidden last:mr-0">
          <span className="word-inner inline-block">{word}</span>
        </span>
      ))}
    </h1>
    {/* Subheadline */}
    <p className="hero-sub mb-10 max-w-md text-lg text-[var(--color-secondary)]/70 leading-relaxed">
      {brand.tagline}
    </p>
    {/* CTA */}
    <MagneticButton href="#contact">{brand.ctaText}</MagneticButton>
  </div>

  {/* Right: Visual */}
  <div className="relative hidden lg:block">
    <div className="hero-bg absolute inset-0 origin-center bg-cover bg-center"
         style={{ backgroundImage: `url(https://picsum.photos/seed/${encodeURIComponent(brand.name)}/900/1200)` }} />
    <div className="absolute inset-0 bg-gradient-to-r from-[var(--color-bg)] via-transparent to-transparent" />
  </div>
</section>

CSS Variables

In globals.css, inject brand tokens:

:root {
  --color-primary: <brand.colors.primary>;
  --color-secondary: <brand.colors.secondary>;
  --color-accent: <brand.colors.accent>;
  --color-bg: <brand.colors.bg>;
}

Mobile Collapse

Below lg: breakpoint:

  • Grid becomes single column (grid-cols-1)
  • Background image moved to a decorative strip below headline
  • All col-span overrides removed
  • Touch targets minimum 44px