306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* HERO — the signature moment. One orchestrated, choreographed page-load with
|
|
* staggered reveals (the single highest-impact "alive" moment on the page).
|
|
* - Real background VIDEO (autoplay/muted/loop/playsinline, poster, cover,
|
|
* deferred load via IO, paused on hidden tab; reduced-motion -> poster only).
|
|
* - ANIMATED SVG: a revenue growth line that DRAWS itself (GSAP DrawSVG) with
|
|
* an area fill that fades up, grid ticks that draw in, and a "live" marker
|
|
* that springs in then pulses.
|
|
* - Headline revealed line-by-line behind a mask (clip + yPercent) with the
|
|
* custom expo curve; eyebrow/sub/CTAs/trust stagger up with a blur mask.
|
|
* - Pointer spotlight (spring-smoothed via CSS var) + parallax on scroll.
|
|
* - Custom easing personality throughout; honors prefers-reduced-motion.
|
|
*/
|
|
import { useEffect, useRef } from "react";
|
|
import Link from "next/link";
|
|
import { gsap, SplitText } from "./gsap";
|
|
import Magnetic from "./Magnetic";
|
|
import { SITE } from "../content";
|
|
|
|
/* Custom GSAP eases that mirror the CSS tokens (registered once, idempotent). */
|
|
gsap.registerEase?.("emilOut", (p) => {
|
|
// approximates cubic-bezier(0.23,1,0.32,1) feel — strong, soft landing
|
|
return 1 - Math.pow(1 - p, 3.2);
|
|
});
|
|
|
|
export default function Hero() {
|
|
const root = useRef<HTMLDivElement>(null);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
// Spring-smoothed spotlight target so the glow trails the cursor with momentum
|
|
// instead of snapping to raw pointer position.
|
|
const target = useRef({ x: 50, y: 40 });
|
|
const current = useRef({ x: 50, y: 40 });
|
|
|
|
const onPointerMove = (e: React.PointerEvent) => {
|
|
const el = root.current;
|
|
if (!el) return;
|
|
const r = el.getBoundingClientRect();
|
|
target.current.x = ((e.clientX - r.left) / r.width) * 100;
|
|
target.current.y = ((e.clientY - r.top) / r.height) * 100;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const el = root.current;
|
|
if (!el) return;
|
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
|
|
// --- spotlight: lerp current toward target each frame (momentum) ---
|
|
let rafId = 0;
|
|
if (!reduce) {
|
|
const tick = () => {
|
|
current.current.x += (target.current.x - current.current.x) * 0.08;
|
|
current.current.y += (target.current.y - current.current.y) * 0.08;
|
|
el.style.setProperty("--mx", `${current.current.x}%`);
|
|
el.style.setProperty("--my", `${current.current.y}%`);
|
|
rafId = requestAnimationFrame(tick);
|
|
};
|
|
rafId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
const video = videoRef.current;
|
|
let io: IntersectionObserver | null = null;
|
|
let onVis: (() => void) | null = null;
|
|
|
|
if (!reduce) {
|
|
if (video) {
|
|
io = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) video.play().catch(() => {});
|
|
else video.pause();
|
|
},
|
|
{ threshold: 0.1 }
|
|
);
|
|
io.observe(video);
|
|
onVis = () => {
|
|
if (document.hidden) video.pause();
|
|
else if (video.getBoundingClientRect().top < window.innerHeight)
|
|
video.play().catch(() => {});
|
|
};
|
|
document.addEventListener("visibilitychange", onVis);
|
|
}
|
|
|
|
const ctx = gsap.context(() => {
|
|
// 1) Headline: split into lines+words, reveal each line from behind a
|
|
// mask (overflow hidden on lines) with a deliberate stagger.
|
|
const split = new SplitText(".hero__h1", {
|
|
type: "lines,words",
|
|
linesClass: "hero__line",
|
|
});
|
|
gsap.set(".hero__h1", { autoAlpha: 1 });
|
|
gsap.set(".hero__line", { overflow: "hidden" });
|
|
// Fast, tight cascade so the FULL headline — including the last word
|
|
// ("revenue.") — finishes well within ~600ms and is never left hidden.
|
|
gsap.from(split.words, {
|
|
yPercent: 118,
|
|
duration: 0.5,
|
|
ease: "emilOut",
|
|
stagger: 0.025,
|
|
delay: 0.12,
|
|
});
|
|
|
|
// 2) Eyebrow, sub, CTAs, trust — lift + blur mask, tight stagger.
|
|
gsap.set(".hero__stagger", { autoAlpha: 0 });
|
|
gsap.to(".hero__stagger", {
|
|
autoAlpha: 1,
|
|
y: 0,
|
|
filter: "blur(0px)",
|
|
duration: 1,
|
|
ease: "emilOut",
|
|
stagger: 0.07,
|
|
delay: 0.55,
|
|
startAt: { y: 26, filter: "blur(5px)" },
|
|
});
|
|
|
|
// 3) ANIMATED SVG — grid draws, the revenue line draws itself, area
|
|
// fades up, the live marker springs in (back ease => overshoot).
|
|
gsap.set(".hero-svg__area", { autoAlpha: 0 });
|
|
const tl = gsap.timeline({ delay: 0.45 });
|
|
tl.from(".hero-svg__grid line", {
|
|
drawSVG: "0%",
|
|
duration: 0.9,
|
|
stagger: 0.06,
|
|
ease: "power2.out",
|
|
})
|
|
.from(
|
|
".hero-svg__line",
|
|
{ drawSVG: "0%", duration: 2, ease: "power2.inOut" },
|
|
"-=0.5"
|
|
)
|
|
.to(".hero-svg__area", { autoAlpha: 1, duration: 1 }, "-=1.2")
|
|
.from(
|
|
".hero-svg__dot",
|
|
{
|
|
scale: 0,
|
|
transformOrigin: "center",
|
|
duration: 0.6,
|
|
ease: "back.out(2.2)",
|
|
},
|
|
"-=0.5"
|
|
)
|
|
.from(
|
|
".hero-svg__dot-ring",
|
|
{ scale: 0.4, autoAlpha: 0, transformOrigin: "center", duration: 0.6, ease: "emilOut" },
|
|
"<"
|
|
);
|
|
|
|
// 4) Parallax: video drifts slower; content + SVG drift at different
|
|
// rates as you scroll away => layered depth.
|
|
gsap.to(".hero__media", {
|
|
yPercent: 16,
|
|
ease: "none",
|
|
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
|
|
});
|
|
gsap.to(".hero-svg", {
|
|
yPercent: 8,
|
|
ease: "none",
|
|
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
|
|
});
|
|
gsap.to(".hero__content", {
|
|
yPercent: -10,
|
|
opacity: 0.3,
|
|
ease: "none",
|
|
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
|
|
});
|
|
}, el);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(rafId);
|
|
io?.disconnect();
|
|
if (onVis) document.removeEventListener("visibilitychange", onVis);
|
|
ctx.revert();
|
|
};
|
|
}
|
|
|
|
// Reduced motion: reveal everything statically, show poster only.
|
|
gsap.set([".hero__h1", ".hero__stagger", ".hero-svg__area"], {
|
|
autoAlpha: 1,
|
|
y: 0,
|
|
filter: "none",
|
|
});
|
|
return () => cancelAnimationFrame(rafId);
|
|
}, []);
|
|
|
|
return (
|
|
<section
|
|
ref={root}
|
|
className="hero"
|
|
aria-labelledby="hero-h1"
|
|
onPointerMove={onPointerMove}
|
|
>
|
|
{/* ---- background video (deferred) + poster + scrims ---- */}
|
|
<div className="hero__media" aria-hidden="true">
|
|
<video
|
|
ref={videoRef}
|
|
className="hero__video"
|
|
poster="/assets/hero-poster.jpg"
|
|
muted
|
|
loop
|
|
playsInline
|
|
preload="none"
|
|
>
|
|
<source src="/assets/hero.mp4" type="video/mp4" />
|
|
</video>
|
|
<div className="hero__scrim" />
|
|
<div className="hero__spotlight" />
|
|
<div className="hero__noise" />
|
|
</div>
|
|
|
|
{/* ---- animated SVG growth chart ---- */}
|
|
<svg
|
|
className="hero-svg"
|
|
viewBox="0 0 1200 600"
|
|
preserveAspectRatio="xMidYMid slice"
|
|
aria-hidden="true"
|
|
>
|
|
<defs>
|
|
<linearGradient id="heroLine" x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stopColor="#3b82f6" />
|
|
<stop offset="55%" stopColor="#8b5cf6" />
|
|
<stop offset="100%" stopColor="#10b981" />
|
|
</linearGradient>
|
|
<linearGradient id="heroArea" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#8b5cf6" stopOpacity="0.35" />
|
|
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
<g className="hero-svg__grid">
|
|
<line x1="0" y1="150" x2="1200" y2="150" />
|
|
<line x1="0" y1="300" x2="1200" y2="300" />
|
|
<line x1="0" y1="450" x2="1200" y2="450" />
|
|
</g>
|
|
<path
|
|
className="hero-svg__area"
|
|
fill="url(#heroArea)"
|
|
d="M0,520 C160,520 220,470 340,430 C470,388 520,300 660,290 C820,278 880,160 1040,120 C1110,102 1160,92 1200,86 L1200,600 L0,600 Z"
|
|
/>
|
|
<path
|
|
className="hero-svg__line"
|
|
fill="none"
|
|
stroke="url(#heroLine)"
|
|
strokeWidth="4"
|
|
strokeLinecap="round"
|
|
d="M0,520 C160,520 220,470 340,430 C470,388 520,300 660,290 C820,278 880,160 1040,120 C1110,102 1160,92 1200,86"
|
|
/>
|
|
<circle className="hero-svg__dot-ring" cx="1200" cy="86" r="18" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0.5" />
|
|
<circle className="hero-svg__dot" cx="1200" cy="86" r="9" fill="#10b981" />
|
|
</svg>
|
|
|
|
{/* ---- content ---- */}
|
|
<div className="hero__content wrap">
|
|
<p className="hero__eyebrow hero__stagger">
|
|
<span className="hero__live" aria-hidden="true" />
|
|
Results-driven digital marketing agency
|
|
</p>
|
|
|
|
<h1 id="hero-h1" className="hero__h1">
|
|
Marketing that grows your <span className="grad">revenue.</span>
|
|
</h1>
|
|
|
|
<p className="hero__sub hero__stagger">
|
|
We run paid, SEO, and content programs built around your revenue
|
|
targets, then show you exactly what they returned. Every month.
|
|
</p>
|
|
|
|
<div className="hero__cta hero__stagger">
|
|
<Magnetic strength={0.4}>
|
|
<Link
|
|
href={SITE.booking}
|
|
className="btn btn--accent"
|
|
data-cursor="Book it"
|
|
>
|
|
Get your growth audit
|
|
</Link>
|
|
</Magnetic>
|
|
<Magnetic strength={0.3}>
|
|
<a href="#work" className="btn btn--ghost" data-cursor="See proof">
|
|
See the results <span className="arrow" aria-hidden="true">→</span>
|
|
</a>
|
|
</Magnetic>
|
|
</div>
|
|
|
|
<dl className="hero__trust hero__stagger">
|
|
<div>
|
|
<dt>$40M+</dt>
|
|
<dd>in client revenue generated</dd>
|
|
</div>
|
|
<div className="hero__trust-div" aria-hidden="true" />
|
|
<div>
|
|
<dt>50+</dt>
|
|
<dd>brands grown</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
|
|
<a
|
|
href="#manifesto"
|
|
className="hero__scroll hero__stagger"
|
|
aria-label="Scroll to content"
|
|
>
|
|
<span className="hero__scroll-line" aria-hidden="true" />
|
|
Scroll
|
|
</a>
|
|
</section>
|
|
);
|
|
}
|