5.4 KiB
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} </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-spanoverrides removed - Touch targets minimum
44px