diff --git a/app/components/BeforeAfter.tsx b/app/components/BeforeAfter.tsx deleted file mode 100644 index a1dda1c..0000000 --- a/app/components/BeforeAfter.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { useRef, useState } from "react"; - -/** - * Interactive "before / after" revenue slider — the visual proof-of-value - * pattern. A draggable divider wipes a muted "before" chart to reveal a - * surging acid "after" chart. Fully keyboard operable via a real range - * input (arrow keys). The headline result is always visible as text, so - * meaning never depends on the interaction. - */ -export default function BeforeAfter({ - before, - after, - caption, -}: { - before: string; - after: string; - caption: string; -}) { - const [pos, setPos] = useState(38); - const wrap = useRef(null); - const id = `ba-${caption.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`; - - const drag = (clientX: number) => { - const r = wrap.current?.getBoundingClientRect(); - if (!r) return; - const p = ((clientX - r.left) / r.width) * 100; - setPos(Math.max(2, Math.min(98, p))); - }; - - return ( -
-
e.buttons === 1 && drag(e.clientX)} - onPointerDown={(e) => drag(e.clientX)} - > - {/* AFTER (full) — surging line */} - - {/* BEFORE (clipped to slider) — flat, muted */} - - - {/* divider + accessible control */} - - - setPos(Number(e.target.value))} - /> -
-
{caption}
-
- ); -} - -/** SVG sparkline. "before" = flat/jagged & muted, "after" = steep climb + acid. */ -function Chart({ variant }: { variant: "before" | "after" }) { - const after = variant === "after"; - const path = after - ? "M0 86 C 40 84, 70 80, 110 70 S 190 30, 240 10" - : "M0 70 C 40 72, 70 66, 110 70 S 190 64, 240 62"; - return ( - - - - - - - - - - - ); -} diff --git a/app/components/CaseCard.tsx b/app/components/CaseCard.tsx new file mode 100644 index 0000000..98470b0 --- /dev/null +++ b/app/components/CaseCard.tsx @@ -0,0 +1,125 @@ +"use client"; + +/** + * CASE CARD — replaces the rejected repetitive photos with a coded, animated + * data visual unique per case: + * - 3D tilt that tracks the pointer (Motion springs, transform-only). + * - An animated bars/sparkline "result chart" drawn in CSS + SVG, growing + * into view (no two cards look alike: different bars, colours, labels). + * - A glare/sheen that follows the cursor across the surface. + * Reduced-motion / touch: flat card, bars still grow on view via CSS. + */ +import { useRef } from "react"; +import { + motion, + useMotionValue, + useSpring, + useTransform, + useReducedMotion, +} from "motion/react"; + +export type CaseData = { + tag: string; + problem: string; + result: string; + how: string; + metricNum: string; + metricLabel: string; + bars: number[]; // 0..100 heights — unique per case + accent: string; // brand accent for this card +}; + +export default function CaseCard({ data, index }: { data: CaseData; index: number }) { + const reduce = useReducedMotion(); + const ref = useRef(null); + const mx = useMotionValue(0.5); + const my = useMotionValue(0.5); + const rx = useSpring(useTransform(my, [0, 1], [7, -7]), { stiffness: 200, damping: 20 }); + const ry = useSpring(useTransform(mx, [0, 1], [-9, 9]), { stiffness: 200, damping: 20 }); + const glare = useTransform( + [mx, my], + ([gx, gy]: number[]) => + `radial-gradient(circle at ${gx * 100}% ${gy * 100}%, rgba(255,255,255,0.14), transparent 45%)` + ); + + const onMove = (e: React.PointerEvent) => { + if (reduce || e.pointerType !== "mouse") return; + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + mx.set((e.clientX - r.left) / r.width); + my.set((e.clientY - r.top) / r.height); + }; + const reset = () => { + mx.set(0.5); + my.set(0.5); + }; + + return ( + +
+
+ {String(index + 1).padStart(2, "0")} + {data.tag} +
+ + {/* coded data visual — unique bars per case */} + + +
+

+ Before + {data.problem} +

+

{data.result}

+

{data.how}

+
+ +
+ {data.metricNum} + {data.metricLabel} +
+ + {!reduce && ( +
+
+ ); +} diff --git a/app/components/CountUp.tsx b/app/components/CountUp.tsx index 46c7d86..e39e941 100644 --- a/app/components/CountUp.tsx +++ b/app/components/CountUp.tsx @@ -1,68 +1,55 @@ "use client"; -import { useEffect, useRef, useState } from "react"; - -type Props = { - to: number; - prefix?: string; - suffix?: string; - decimals?: number; - duration?: number; -}; - /** - * Count-up that fires once when scrolled into view. Renders the final - * value immediately under reduced motion (and as SSR fallback) so the - * real number is always present for assistive tech and no-JS. + * Animated number that counts up when it enters the viewport (once). + * - Motion `animate()` drives a raw value; we format with prefix/suffix/decimals. + * - prefers-reduced-motion: jumps straight to the final value. + * - Real number text stays in the DOM for SEO / screen readers. */ +import { useEffect, useRef, useState } from "react"; +import { animate, useInView, useReducedMotion } from "motion/react"; + export default function CountUp({ to, prefix = "", suffix = "", decimals = 0, - duration = 1600, -}: Props) { + duration = 1.8, +}: { + to: number; + prefix?: string; + suffix?: string; + decimals?: number; + duration?: number; +}) { const ref = useRef(null); - const [val, setVal] = useState(to); - const done = useRef(false); + const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" }); + const reduce = useReducedMotion(); + const [val, setVal] = useState(0); useEffect(() => { - const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!inView) return; if (reduce) { setVal(to); return; } - setVal(0); - const node = ref.current; - if (!node) return; + const controls = animate(0, to, { + duration, + ease: [0.16, 1, 0.3, 1], + onUpdate: (v) => setVal(v), + }); + return () => controls.stop(); + }, [inView, to, duration, reduce]); - const io = new IntersectionObserver( - ([e]) => { - if (!e.isIntersecting || done.current) return; - done.current = true; - const start = performance.now(); - const tick = (now: number) => { - const p = Math.min((now - start) / duration, 1); - const eased = 1 - Math.pow(1 - p, 3); - setVal(to * eased); - if (p < 1) requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); - io.disconnect(); - }, - { threshold: 0.4 } - ); - io.observe(node); - return () => io.disconnect(); - }, [to, duration]); + const display = val.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); return ( {prefix} - {val.toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - })} + {display} {suffix} ); diff --git a/app/components/Cursor.tsx b/app/components/Cursor.tsx index f229b7f..962999a 100644 --- a/app/components/Cursor.tsx +++ b/app/components/Cursor.tsx @@ -1,64 +1,68 @@ "use client"; -import { useEffect, useRef } from "react"; - /** - * A small "terminal crosshair" cursor that grows into a reading ring over - * interactive targets. Pointer-device only; hidden for touch / reduced - * motion. Purely decorative (aria-hidden) — never the only affordance. + * Custom cursor: a spring-following dot + a larger ring. + * - Grows + shows a contextual label when hovering [data-cursor] targets. + * - Hidden on touch / coarse pointers and when prefers-reduced-motion is set + * (falls back to the native cursor, which globals.css restores). + * - Built with Motion springs for buttery follow without layout thrash + * (transform-only, GPU-friendly). */ +import { useEffect, useState } from "react"; +import { motion, useMotionValue, useSpring } from "motion/react"; + export default function Cursor() { - const ring = useRef(null); - const label = useRef(null); + const [enabled, setEnabled] = useState(false); + const [hovering, setHovering] = useState(false); + const [label, setLabel] = useState(""); + + const x = useMotionValue(-100); + const y = useMotionValue(-100); + const ringX = useSpring(x, { stiffness: 350, damping: 30, mass: 0.6 }); + const ringY = useSpring(y, { stiffness: 350, damping: 30, mass: 0.6 }); + const dotX = useSpring(x, { stiffness: 900, damping: 40 }); + const dotY = useSpring(y, { stiffness: 900, damping: 40 }); useEffect(() => { const fine = window.matchMedia("(pointer: fine)").matches; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!fine || reduce) return; - - const el = ring.current; - const lab = label.current; - if (!el || !lab) return; - - let x = window.innerWidth / 2; - let y = window.innerHeight / 2; - let cx = x; - let cy = y; - let raf = 0; + setEnabled(true); + document.documentElement.classList.add("has-custom-cursor"); const move = (e: PointerEvent) => { - x = e.clientX; - y = e.clientY; - const t = e.target as HTMLElement; - const tgt = t?.closest("a, button, [data-cursor]"); - el.dataset.active = tgt ? "1" : "0"; - const txt = (tgt as HTMLElement)?.dataset?.cursor; - lab.textContent = txt || ""; - el.dataset.hasLabel = txt ? "1" : "0"; + x.set(e.clientX); + y.set(e.clientY); + const target = (e.target as HTMLElement)?.closest( + "[data-cursor], a, button" + ); + if (target) { + setHovering(true); + setLabel(target.getAttribute("data-cursor") || ""); + } else { + setHovering(false); + setLabel(""); + } }; - const loop = () => { - cx += (x - cx) * 0.2; - cy += (y - cy) * 0.2; - el.style.transform = `translate3d(${cx}px, ${cy}px, 0) translate(-50%, -50%)`; - raf = requestAnimationFrame(loop); - }; - - document.body.classList.add("has-cursor"); - window.addEventListener("pointermove", move); - raf = requestAnimationFrame(loop); - + window.addEventListener("pointermove", move, { passive: true }); return () => { window.removeEventListener("pointermove", move); - cancelAnimationFrame(raf); - document.body.classList.remove("has-cursor"); + document.documentElement.classList.remove("has-custom-cursor"); }; - }, []); + }, [x, y]); + + if (!enabled) return null; return ( - + + {isOpen && ( + +

{f.a}

+
+ )} +
+ ); })} - + ); } diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx new file mode 100644 index 0000000..39a7910 --- /dev/null +++ b/app/components/Hero.tsx @@ -0,0 +1,238 @@ +"use client"; + +/** + * HERO — the signature moment. Fixes the client's 4 complaints head-on: + * (2) real background VIDEO (autoplay/muted/loop/playsinline, poster, cover, + * deferred load, reduced-motion -> poster still only). + * (3) ANIMATED SVG: a revenue growth line that draws itself (GSAP DrawSVG) + * with an area fill that fades up and a pulsing "live" marker. + * (1) real interaction: SplitText word reveal, cursor spotlight following the + * pointer, magnetic CTAs, parallax on scroll. + * (4) zero raster imagery beyond the video poster. + */ +import { useEffect, useRef } from "react"; +import Link from "next/link"; +import { gsap, SplitText } from "./gsap"; +import Magnetic from "./Magnetic"; +import { SITE } from "../content"; + +export default function Hero() { + const root = useRef(null); + const videoRef = useRef(null); + + // Pointer spotlight (CSS vars on the hero -> radial-gradient follows cursor). + const onPointerMove = (e: React.PointerEvent) => { + const el = root.current; + if (!el) return; + const r = el.getBoundingClientRect(); + el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`); + el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`); + }; + + useEffect(() => { + const el = root.current; + if (!el) return; + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + // Defer the video: only start it once it's actually on screen + ready, + // and pause it when the tab/section is hidden (saves battery + main thread). + const video = videoRef.current; + if (video && !reduce) { + const io = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) video.play().catch(() => {}); + else video.pause(); + }, + { threshold: 0.1 } + ); + io.observe(video); + const 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 words/lines and reveal with a mask. + const split = new SplitText(".hero__h1", { type: "lines,words" }); + gsap.set(".hero__h1", { autoAlpha: 1 }); + gsap.from(split.words, { + yPercent: 120, + opacity: 0, + duration: 1, + ease: "expo.out", + stagger: 0.04, + delay: 0.15, + }); + + // 2) Staggered entrance for eyebrow, sub, CTAs, trust row. + gsap.from(".hero__stagger", { + y: 24, + opacity: 0, + duration: 0.9, + ease: "expo.out", + stagger: 0.08, + delay: 0.5, + }); + + // 3) ANIMATED SVG — the growth line draws itself, area fades in, + // grid ticks pop, live marker pulses. + gsap.set(".hero-svg__area", { autoAlpha: 0 }); + const tl = gsap.timeline({ delay: 0.4 }); + tl.from(".hero-svg__grid line", { + drawSVG: "0%", + duration: 0.8, + stagger: 0.04, + ease: "power2.out", + }) + .from( + ".hero-svg__line", + { drawSVG: "0%", duration: 1.8, ease: "power2.inOut" }, + "-=0.4" + ) + .to(".hero-svg__area", { autoAlpha: 1, duration: 0.9 }, "-=1.1") + .from( + ".hero-svg__dot", + { scale: 0, transformOrigin: "center", duration: 0.5, ease: "back.out(2)" }, + "-=0.5" + ); + + // 4) Parallax: video drifts slower than content as you scroll away. + gsap.to(".hero__media", { + yPercent: 18, + ease: "none", + scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true }, + }); + gsap.to(".hero__content", { + yPercent: -8, + opacity: 0.4, + ease: "none", + scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true }, + }); + }, el); + + return () => { + io.disconnect(); + document.removeEventListener("visibilitychange", onVis); + ctx.revert(); + }; + } + + // Reduced motion: still reveal the SVG line statically (no draw), show poster. + gsap.set([".hero__h1", ".hero__stagger", ".hero-svg__area"], { autoAlpha: 1 }); + }, []); + + return ( +
+ {/* ---- background video (deferred) + poster + scrims ---- */} +
+ ); +} diff --git a/app/components/KineticHeadline.tsx b/app/components/KineticHeadline.tsx deleted file mode 100644 index ff208f4..0000000 --- a/app/components/KineticHeadline.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import { gsap } from "gsap"; -import SplitType from "split-type"; - -/** - * Splits the hero H1 into lines + chars and reveals them with a staggered - * mask-up on load — the orchestrated "page load" delight moment. - * Under reduced motion the text is simply shown as-is. The real text is - * always in the DOM (SplitType only re-wraps the same characters), so SEO - * and screen readers read the full heading. - */ -export default function KineticHeadline({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - const ref = useRef(null); - - useEffect(() => { - const el = ref.current; - if (!el) return; - const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; - if (reduce) { - el.style.opacity = "1"; - return; - } - - const split = new SplitType(el, { types: "lines,words" }); - el.style.opacity = "1"; - - // wrap each line in an overflow-hidden mask - split.lines?.forEach((line) => { - const mask = document.createElement("span"); - mask.className = "line-mask"; - line.parentNode?.insertBefore(mask, line); - mask.appendChild(line); - }); - - const tween = gsap.from(split.words || [], { - yPercent: 115, - opacity: 0, - rotateZ: 2, - duration: 1, - ease: "expo.out", - stagger: 0.04, - delay: 0.15, - }); - - return () => { - tween.kill(); - split.revert(); - }; - }, []); - - return ( -

- {children} -

- ); -} diff --git a/app/components/Magnetic.tsx b/app/components/Magnetic.tsx new file mode 100644 index 0000000..fe6874b --- /dev/null +++ b/app/components/Magnetic.tsx @@ -0,0 +1,53 @@ +"use client"; + +/** + * Magnetic wrapper: the child is pulled toward the cursor while hovered, + * then springs back on leave. Transform-only (GPU). No-op on coarse pointers + * and with prefers-reduced-motion, so keyboard/touch users get a static, + * fully-clickable element. + */ +import { useRef } from "react"; +import { motion, useMotionValue, useSpring } from "motion/react"; + +export default function Magnetic({ + children, + strength = 0.4, + className, +}: { + children: React.ReactNode; + strength?: number; + className?: string; +}) { + const ref = useRef(null); + const x = useMotionValue(0); + const y = useMotionValue(0); + const sx = useSpring(x, { stiffness: 250, damping: 18, mass: 0.4 }); + const sy = useSpring(y, { stiffness: 250, damping: 18, mass: 0.4 }); + + const onMove = (e: React.PointerEvent) => { + if (e.pointerType !== "mouse") return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const el = ref.current; + if (!el) return; + const r = el.getBoundingClientRect(); + x.set((e.clientX - (r.left + r.width / 2)) * strength); + y.set((e.clientY - (r.top + r.height / 2)) * strength); + }; + + const reset = () => { + x.set(0); + y.set(0); + }; + + return ( + + {children} + + ); +} diff --git a/app/components/ProcessLoop.tsx b/app/components/ProcessLoop.tsx new file mode 100644 index 0000000..f396841 --- /dev/null +++ b/app/components/ProcessLoop.tsx @@ -0,0 +1,133 @@ +"use client"; + +/** + * PROCESS — "The Feedback Loop" as pinned scroll storytelling. + * - GSAP ScrollTrigger pins the panel while the user scrolls through 4 steps. + * - A single SVG glyph MORPHS between 4 shapes (MorphSVGPlugin) — magnifier + * (Audit) -> route/plan -> bolt (Execute) -> chart (Report) — the animated-SVG + * moment for this section. + * - A progress rail + numbered steps light up in sync (scrub). + * - prefers-reduced-motion: no pin, no morph; steps render as a static list. + */ +import { useEffect, useRef } from "react"; +// Side-effect import registers ScrollTrigger + MorphSVGPlugin; gsap exposes both. +import { gsap } from "./gsap"; +import { processSteps } from "../content"; + +// Four target paths for the morph (drawn on a 100x100 canvas). +const SHAPES = [ + // Audit — magnifying glass + "M44 20a24 24 0 1 0 15 43l18 18 7-7-18-18A24 24 0 0 0 44 20Zm0 10a14 14 0 1 1 0 28 14 14 0 0 1 0-28Z", + // Plan — connected route / nodes + "M22 30a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm40 40a8 8 0 1 1 16 0 8 8 0 0 1-16 0ZM30 38v8a16 16 0 0 0 16 16h8a16 16 0 0 1 16 16v-2 2h-8a26 26 0 0 1-26-26v-8Z", + // Execute — lightning bolt + "M55 14 26 58h20l-6 30 32-46H50l9-28Z", + // Report — bar chart trending up + "M22 78V52h12v26Zm22 0V36h12v42Zm22 0V20h12v58ZM20 30 40 22l16 8 26-14", +]; + +export default function ProcessLoop() { + const root = useRef(null); + + useEffect(() => { + const el = root.current; + if (!el) return; + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduce) return; + + const ctx = gsap.context(() => { + const morph = el.querySelector(".loop-glyph__path"); + if (!morph) return; + const steps = gsap.utils.toArray(".loop-step"); + const total = SHAPES.length; + + // One pinned, scrubbed timeline drives the morph + rotation + rail fill. + const tl = gsap.timeline({ + scrollTrigger: { + trigger: ".loop-pin", + start: "top top", + end: () => `+=${window.innerHeight * (total - 0.2)}`, + pin: true, + scrub: 0.6, + anticipatePin: 1, + onUpdate: (self) => { + // Robust, independent active-step highlight tied to raw progress. + const idx = Math.min( + total - 1, + Math.floor(self.progress * total + 0.0001) + ); + steps.forEach((s, j) => s.classList.toggle("is-active", j === idx)); + }, + }, + }); + + steps[0]?.classList.add("is-active"); + + SHAPES.forEach((shape, i) => { + if (i === 0) return; // start shape already in markup + const at = i - 1; + tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at) + .to(".loop-glyph", { rotate: i * 6, duration: 1, ease: "power2.inOut" }, at) + .to( + ".loop-rail__fill", + { scaleY: i / (total - 1), duration: 1, ease: "none" }, + at + ); + }); + }, el); + + return () => ctx.revert(); + }, []); + + return ( +
+
+
+
+

+ + The Feedback Loop +

+

+ How it works +

+
+ +
+ {/* morphing glyph */} + + + {/* steps + progress rail */} +
+ +
    + {processSteps.map((p) => ( +
  1. + {p.n} +
    +

    {p.name}

    +

    {p.desc}

    +
    +
  2. + ))} +
+
+
+
+
+
+ ); +} diff --git a/app/components/Reveal.tsx b/app/components/Reveal.tsx index eb507b5..e077b54 100644 --- a/app/components/Reveal.tsx +++ b/app/components/Reveal.tsx @@ -1,49 +1,86 @@ "use client"; -import { useEffect, useRef } from "react"; - /** - * Lightweight scroll reveal via IntersectionObserver (no GSAP dependency - * so it works even before the smooth-scroll module mounts). Adds .is-in. - * CSS already shows content under reduced motion. + * In-view reveal. Fades + lifts content when it scrolls into view (once). + * - Motion `whileInView` with a viewport margin so it triggers slightly early. + * - `stagger` cascades direct children via Motion variants. + * - Honors prefers-reduced-motion (renders fully visible, no transform). */ -export default function Reveal({ - children, - as: Tag = "div", - className = "", - delay = 0, -}: { +import { motion, type Variants } from "motion/react"; +import { useReducedMotion } from "motion/react"; + +type Props = { children: React.ReactNode; - as?: keyof React.JSX.IntrinsicElements; className?: string; delay?: number; -}) { - const ref = useRef(null); + y?: number; + stagger?: number; + as?: "div" | "section" | "ul" | "ol" | "li" | "p" | "figure" | "header"; +}; - useEffect(() => { - const el = ref.current; - if (!el) return; - const io = new IntersectionObserver( - ([e]) => { - if (e.isIntersecting) { - el.classList.add("is-in"); - io.disconnect(); - } - }, - { threshold: 0.15, rootMargin: "0px 0px -8% 0px" } +export default function Reveal({ + children, + className, + delay = 0, + y = 26, + stagger, + as = "div", +}: Props) { + const reduce = useReducedMotion(); + const MotionTag = motion[as] as typeof motion.div; + + if (stagger) { + const parent: Variants = { + hidden: {}, + show: { transition: { staggerChildren: reduce ? 0 : stagger, delayChildren: delay } }, + }; + return ( + + {children} + ); - io.observe(el); - return () => io.disconnect(); - }, []); + } - const Comp = Tag as React.ElementType; return ( - {children} - + + ); +} + +/** A child item that participates in a parent . */ +export function RevealItem({ + children, + className, + y = 22, + as = "div", +}: { + children: React.ReactNode; + className?: string; + y?: number; + as?: "div" | "li" | "p"; +}) { + const reduce = useReducedMotion(); + const MotionTag = motion[as] as typeof motion.div; + const item: Variants = { + hidden: reduce ? { opacity: 1 } : { opacity: 0, y }, + show: { opacity: 1, y: 0, transition: { duration: 0.65, ease: [0.16, 1, 0.3, 1] } }, + }; + return ( + + {children} + ); } diff --git a/app/components/RevenueField.tsx b/app/components/RevenueField.tsx deleted file mode 100644 index 9e3c7da..0000000 --- a/app/components/RevenueField.tsx +++ /dev/null @@ -1,206 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl"; - -/** - * Custom WebGL signature: a flowing "revenue field" — animated contour - * lines that rise and surge like a growth chart / topographic gain map, - * rendered as ink hairlines on paper with an acid-green crest threading - * through. Reacts gently to the pointer. - * - * Constraints honored: - * - "use client", all GL created in useEffect, full cleanup - * - no window/document access during SSR/render - * - pauses RAF when the tab is hidden or canvas is offscreen - * - respects prefers-reduced-motion (renders one static frame, no loop) - * - decorative only (aria-hidden); never the sole carrier of meaning - */ -export default function RevenueField() { - const wrap = useRef(null); - - useEffect(() => { - const host = wrap.current; - if (!host) return; - - const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; - - const renderer = new Renderer({ - alpha: true, - antialias: true, - dpr: Math.min(window.devicePixelRatio || 1, 2), - }); - const gl = renderer.gl; - gl.clearColor(0, 0, 0, 0); - host.appendChild(gl.canvas); - gl.canvas.style.width = "100%"; - gl.canvas.style.height = "100%"; - gl.canvas.style.display = "block"; - - const geometry = new Triangle(gl); - - const program = new Program(gl, { - uniforms: { - uTime: { value: 0 }, - uRes: { value: new Vec2(1, 1) }, - uMouse: { value: new Vec2(0.5, 0.5) }, - // ink + emerald crest (the hero sits on light paper, so the crest - // uses the deeper "gain" green to stay perceptible, not invisible lime) - uInk: { value: [0.078, 0.075, 0.059] }, - uAcid: { value: [0.039, 0.431, 0.278] }, - }, - vertex: /* glsl */ ` - attribute vec2 uv; - attribute vec2 position; - varying vec2 vUv; - void main() { - vUv = uv; - gl_Position = vec4(position, 0.0, 1.0); - } - `, - fragment: /* glsl */ ` - precision highp float; - varying vec2 vUv; - uniform float uTime; - uniform vec2 uRes; - uniform vec2 uMouse; - uniform vec3 uInk; - uniform vec3 uAcid; - - // cheap value noise - float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } - float noise(vec2 p){ - vec2 i = floor(p); vec2 f = fract(p); - vec2 u = f*f*(3.0-2.0*f); - return mix(mix(hash(i), hash(i+vec2(1,0)), u.x), - mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), u.x), u.y); - } - float fbm(vec2 p){ - float v = 0.0; float a = 0.5; - for(int i=0;i<5;i++){ v += a*noise(p); p *= 2.0; a *= 0.5; } - return v; - } - - void main() { - vec2 uv = vUv; - float aspect = uRes.x / max(uRes.y, 1.0); - vec2 p = uv; - p.x *= aspect; - - // mouse influence — a gentle lift around the pointer - vec2 m = uMouse; m.x *= aspect; - float md = distance(p, m); - float lift = smoothstep(0.6, 0.0, md) * 0.18; - - float t = uTime * 0.06; - - // a rising surface: base height climbs left->right (growth) - float climb = uv.x * 0.55; - float field = fbm(p * 2.4 + vec2(t, t * 0.4)) + climb + lift; - field += fbm(p * 5.0 - vec2(t * 0.7, 0.0)) * 0.25; - - // contour lines from the height field - float lines = abs(fract(field * 9.0) - 0.5); - float w = fwidth(field * 9.0) * 1.2; - float contour = 1.0 - smoothstep(0.0, w, lines); - - // crest highlight: the topmost band glows acid (the "gain") - float crest = smoothstep(0.62, 0.86, field) * smoothstep(0.96, 0.7, field); - - vec3 col = mix(uInk, uAcid, crest * 0.9); - float alpha = contour * (0.20 + crest * 0.85); - - // soft right/top fade so type stays readable, lines vignette off edges - alpha *= smoothstep(0.0, 0.18, uv.x); - alpha *= smoothstep(0.0, 0.14, uv.y) * smoothstep(1.0, 0.82, uv.y); - - gl_FragColor = vec4(col, alpha); - } - `, - }); - - const mesh = new Mesh(gl, { geometry, program }); - - const resize = () => { - const w = host.clientWidth; - const h = host.clientHeight; - renderer.setSize(w, h); - program.uniforms.uRes.value.set(w, h); - }; - resize(); - const ro = new ResizeObserver(resize); - ro.observe(host); - - // pointer (eased) - const target = new Vec2(0.5, 0.5); - const onPointer = (e: PointerEvent) => { - const r = host.getBoundingClientRect(); - target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height); - }; - window.addEventListener("pointermove", onPointer); - - let raf = 0; - let running = true; - const start = performance.now(); - - const frame = (now: number) => { - if (!running) return; - const u = program.uniforms; - u.uTime.value = (now - start) / 1000; - (u.uMouse.value as Vec2).x += (target.x - (u.uMouse.value as Vec2).x) * 0.05; - (u.uMouse.value as Vec2).y += (target.y - (u.uMouse.value as Vec2).y) * 0.05; - renderer.render({ scene: mesh }); - raf = requestAnimationFrame(frame); - }; - - // visibility: pause GL when tab hidden or canvas scrolled offscreen - const onVisibility = () => { - if (document.hidden) { - running = false; - cancelAnimationFrame(raf); - } else if (!reduce) { - running = true; - raf = requestAnimationFrame(frame); - } - }; - document.addEventListener("visibilitychange", onVisibility); - - const io = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !document.hidden && !reduce) { - if (!running) { - running = true; - raf = requestAnimationFrame(frame); - } - } else { - running = false; - cancelAnimationFrame(raf); - } - }, - { threshold: 0 } - ); - io.observe(host); - - if (reduce) { - // single static frame, no animation loop - program.uniforms.uTime.value = 8; - renderer.render({ scene: mesh }); - } else { - raf = requestAnimationFrame(frame); - } - - return () => { - running = false; - cancelAnimationFrame(raf); - window.removeEventListener("pointermove", onPointer); - document.removeEventListener("visibilitychange", onVisibility); - io.disconnect(); - ro.disconnect(); - const ext = gl.getExtension("WEBGL_lose_context"); - if (ext) ext.loseContext(); - if (gl.canvas.parentNode) gl.canvas.parentNode.removeChild(gl.canvas); - }; - }, []); - - return -