198 lines
5.4 KiB
Markdown
198 lines
5.4 KiB
Markdown
# Phase 2 — Cinematic Hero Animation
|
|
|
|
Generate the hero section using GSAP + ScrollTrigger. Uses the brand data from Phase 1.
|
|
|
|
## Dependencies
|
|
|
|
```bash
|
|
npm install gsap @gsap/react lenis
|
|
```
|
|
|
|
Register plugins once at app root (in `layout.tsx` or a client provider):
|
|
```tsx
|
|
'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
|
|
```ts
|
|
// 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
|
|
```ts
|
|
gsap.from('.hero-sub', {
|
|
y: 30,
|
|
opacity: 0,
|
|
duration: 0.7,
|
|
delay: 0.4,
|
|
ease: 'power3.out',
|
|
})
|
|
```
|
|
|
|
### 3. CTA Button — Spring Entrance
|
|
```ts
|
|
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)
|
|
```ts
|
|
gsap.to('.hero-bg', {
|
|
scale: 1.06,
|
|
duration: 8,
|
|
ease: 'none',
|
|
repeat: -1,
|
|
yoyo: true,
|
|
})
|
|
```
|
|
|
|
### 5. ScrollTrigger — Pin + Parallax Exit
|
|
```ts
|
|
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`
|
|
|
|
```tsx
|
|
'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.
|
|
|
|
```tsx
|
|
<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:
|
|
```css
|
|
: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`
|