diff --git a/app/components/BeforeAfter.tsx b/app/components/BeforeAfter.tsx new file mode 100644 index 0000000..a1dda1c --- /dev/null +++ b/app/components/BeforeAfter.tsx @@ -0,0 +1,103 @@ +"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/CaseStudies.tsx b/app/components/CaseStudies.tsx deleted file mode 100644 index 60efa8a..0000000 --- a/app/components/CaseStudies.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -/** - * CaseStudies — large film-cell cards. Each is a real
with the - * generated capsule visual, a problem -> result narrative, the method, and the - * headline metric. Alternating media side creates an editorial rhythm; refined - * scale-on-hover on the image. Images are explicit-sized + lazy-loaded. - */ - -import Reveal from "./Reveal"; -import { cases } from "../content"; - -export default function CaseStudies() { - return ( -
-
- -

Case studies

-

Proof, not promises.

-

- A few of the businesses we've grown. Same approach every time: - tie the work to the revenue, then prove it. -

-
- -
- {cases.map((c, i) => ( - -
-
- {c.tag} - {/* eslint-disable-next-line @next/next/no-img-element */} - {c.alt} -
-
-

{c.problem}

-

- {c.result} -

-

{c.how}

-

- {c.metricNum} - {c.metricLabel} -

-
-
-
- ))} -
- -
- - View all work → - -
-
-
- ); -} diff --git a/app/components/CountUp.tsx b/app/components/CountUp.tsx index 52102fb..46c7d86 100644 --- a/app/components/CountUp.tsx +++ b/app/components/CountUp.tsx @@ -1,74 +1,69 @@ "use client"; -/** - * CountUp — animates a number from 0 to its target when it scrolls into view. - * Uses GSAP + ScrollTrigger, formats with optional prefix/suffix/decimals and - * tabular figures. Under reduced-motion it renders the final value immediately. - * The full value is also written to the DOM on mount so it is correct even if - * JS/animation never runs (accessible + SSR-safe). - */ - -import { useEffect, useRef } from "react"; -import { gsap } from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; - -gsap.registerPlugin(ScrollTrigger); +import { useEffect, useRef, useState } from "react"; type Props = { - value: number; + to: number; prefix?: string; suffix?: string; decimals?: number; - className?: string; + 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. + */ export default function CountUp({ - value, + to, prefix = "", suffix = "", decimals = 0, - className = "", + duration = 1600, }: Props) { const ref = useRef(null); - - const format = (n: number) => - `${prefix}${n.toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - })}${suffix}`; + const [val, setVal] = useState(to); + const done = useRef(false); useEffect(() => { - const el = ref.current; - if (!el) return; - - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - el.textContent = format(value); + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduce) { + setVal(to); return; } + setVal(0); + const node = ref.current; + if (!node) return; - const obj = { n: 0 }; - el.textContent = format(0); + 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 ctx = gsap.context(() => { - gsap.to(obj, { - n: value, - duration: 2, - ease: "power2.out", - scrollTrigger: { trigger: el, start: "top 85%", once: true }, - onUpdate: () => { - el.textContent = format(obj.n); - }, - }); - }, el); - - return () => ctx.revert(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, prefix, suffix, decimals]); - - // Render full value at SSR/first paint; the effect resets to 0 then animates. return ( - - {format(value)} + + {prefix} + {val.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + })} + {suffix} ); } diff --git a/app/components/Cursor.tsx b/app/components/Cursor.tsx index a4c6697..f229b7f 100644 --- a/app/components/Cursor.tsx +++ b/app/components/Cursor.tsx @@ -1,79 +1,64 @@ "use client"; -/** - * Cursor — a character cursor with a difference-blend "lens". - * - * A small dot tracks tightly; a larger ring trails with easing and inverts the - * content beneath it (mix-blend-mode: difference). Over interactive elements it - * grows; over elements carrying data-cursor it swaps in a contextual label - * (e.g. "ver"). Disabled on touch / coarse pointers. Native cursor remains as a - * fallback so the page is always usable. - */ - import { useEffect, useRef } from "react"; -import { gsap } from "gsap"; +/** + * 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. + */ export default function Cursor() { - const dot = useRef(null); const ring = useRef(null); const label = useRef(null); useEffect(() => { - if (window.matchMedia("(pointer: coarse)").matches) return; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - if (!dot.current || !ring.current) return; + const fine = window.matchMedia("(pointer: fine)").matches; + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!fine || reduce) return; - document.body.classList.add("has-custom-cursor"); + const el = ring.current; + const lab = label.current; + if (!el || !lab) return; - const xD = gsap.quickTo(dot.current, "x", { duration: 0.12, ease: "power3" }); - const yD = gsap.quickTo(dot.current, "y", { duration: 0.12, ease: "power3" }); - const xR = gsap.quickTo(ring.current, "x", { duration: 0.55, ease: "power3" }); - const yR = gsap.quickTo(ring.current, "y", { duration: 0.55, ease: "power3" }); + let x = window.innerWidth / 2; + let y = window.innerHeight / 2; + let cx = x; + let cy = y; + let raf = 0; - const move = (e: MouseEvent) => { - xD(e.clientX); - yD(e.clientY); - xR(e.clientX); - yR(e.clientY); + 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"; }; - const over = (e: Event) => { - const t = (e.target as HTMLElement).closest( - "a,button,.hoverable,[data-cursor]" - ); - if (!t) return; - ring.current?.classList.add("cursor-ring--big"); - const text = t.getAttribute("data-cursor"); - if (text && label.current) { - label.current.textContent = text; - ring.current?.classList.add("cursor-ring--label"); - } - }; - const out = (e: Event) => { - const t = (e.target as HTMLElement).closest( - "a,button,.hoverable,[data-cursor]" - ); - if (!t) return; - ring.current?.classList.remove("cursor-ring--big", "cursor-ring--label"); + 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); }; - window.addEventListener("mousemove", move, { passive: true }); - document.addEventListener("mouseover", over); - document.addEventListener("mouseout", out); + document.body.classList.add("has-cursor"); + window.addEventListener("pointermove", move); + raf = requestAnimationFrame(loop); + return () => { - document.body.classList.remove("has-custom-cursor"); - window.removeEventListener("mousemove", move); - document.removeEventListener("mouseover", over); - document.removeEventListener("mouseout", out); + window.removeEventListener("pointermove", move); + cancelAnimationFrame(raf); + document.body.classList.remove("has-cursor"); }; }, []); return ( - <> - - + ); + })} + ); } diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx deleted file mode 100644 index 79e4c49..0000000 --- a/app/components/Hero.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client"; - -/** - * Hero — type IS the hero. A display headline at 8-14vw reveals line by line - * (manual masked split via KineticText), the iridescent wave asset floats as a - * tasteful accent layer, and a trust line anchors the claim. Parallax on the - * accent + headline as the hero scrolls out. - */ - -import { useEffect, useRef } from "react"; -import { gsap } from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; -import KineticText from "./KineticText"; -import Magnetic from "./Magnetic"; - -gsap.registerPlugin(ScrollTrigger); - -export default function Hero() { - const root = useRef(null); - - useEffect(() => { - const el = root.current; - if (!el) return; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - - const ctx = gsap.context(() => { - const tl = gsap.timeline({ defaults: { ease: "power4.out" } }); - tl.from(".hero__eyebrow", { y: 18, opacity: 0, duration: 0.9, delay: 0.15 }) - .from(".hero__accent", { opacity: 0, scale: 1.06, duration: 1.4 }, 0) - .from(".hero__sub", { y: 22, opacity: 0, duration: 0.9 }, "-=0.2") - .from(".hero__actions > *", { y: 20, opacity: 0, stagger: 0.1, duration: 0.7 }, "-=0.5") - .from(".hero__trust", { opacity: 0, duration: 0.8 }, "-=0.4"); - - // parallax: accent drifts up, headline lifts + fades as hero scrolls out - gsap.to(".hero__accent", { - yPercent: -18, - ease: "none", - scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true }, - }); - gsap.to(".hero__display", { - yPercent: 14, - opacity: 0.18, - ease: "none", - scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true }, - }); - }, el); - - return () => ctx.revert(); - }, []); - - return ( -
- {/* iridescent wave accent — decorative texture layer */} - {/* eslint-disable-next-line @next/next/no-img-element */} - - -
-

-

- -

- - - -

- -

- We're a results-driven digital marketing agency. We run paid, SEO, - and content programs built around your revenue targets, - then show you what they returned. Every month. -

- - - -

- $40M+ in client revenue generated -

-
- - - scroll - - -
- ); -} diff --git a/app/components/Iridescence.tsx b/app/components/Iridescence.tsx deleted file mode 100644 index d7bf2c7..0000000 --- a/app/components/Iridescence.tsx +++ /dev/null @@ -1,236 +0,0 @@ -"use client"; - -/** - * Iridescence — generative WebGL backdrop. - * - * A living interpretation of the brand wave: domain-warped fractal noise mapped - * onto the brand spectrum (violet -> blue -> rose -> emerald) glowing out of a - * near-black field, with a faint rainbow refraction band and animated film - * grain. Drifts on its own and leans toward the cursor. Falls back to a static - * dark CSS gradient when WebGL is unavailable or the user prefers reduced - * motion. - * - * Renders behind everything (fixed, -z) and is purely decorative (aria-hidden). - */ - -import { useEffect, useRef } from "react"; -import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl"; - -const VERT = /* glsl */ ` - attribute vec2 position; - void main() { - gl_Position = vec4(position, 0.0, 1.0); - } -`; - -const FRAG = /* glsl */ ` - precision highp float; - - uniform float uTime; - uniform vec2 uResolution; - uniform vec2 uMouse; // 0..1, smoothed - uniform float uIntensity; // global motion amount (0 for reduced motion) - - // Brand palette anchors - const vec3 VIOLET = vec3(0.545, 0.361, 0.965); // #8b5cf6 - const vec3 BLUE = vec3(0.231, 0.510, 0.965); // #3b82f6 - const vec3 ROSE = vec3(0.957, 0.475, 0.851); // soft pink - const vec3 MINT = vec3(0.204, 0.827, 0.600); // #34d399 emerald - const vec3 BASE = vec3(0.031, 0.031, 0.047); // near-black #08080c - - // -- hash / value noise -------------------------------------------------- - vec2 hash22(vec2 p) { - p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); - return fract(sin(p) * 43758.5453123); - } - - float noise(vec2 p) { - vec2 i = floor(p); - vec2 f = fract(p); - vec2 u = f * f * (3.0 - 2.0 * f); - float a = dot(hash22(i + vec2(0.0, 0.0)) - 0.5, f - vec2(0.0, 0.0)); - float b = dot(hash22(i + vec2(1.0, 0.0)) - 0.5, f - vec2(1.0, 0.0)); - float c = dot(hash22(i + vec2(0.0, 1.0)) - 0.5, f - vec2(0.0, 1.0)); - float d = dot(hash22(i + vec2(1.0, 1.0)) - 0.5, f - vec2(1.0, 1.0)); - return 0.5 + mix(mix(a, b, u.x), mix(c, d, u.x), u.y); - } - - float fbm(vec2 p) { - float v = 0.0; - float amp = 0.55; - mat2 rot = mat2(0.8, -0.6, 0.6, 0.8); - for (int i = 0; i < 5; i++) { - v += amp * noise(p); - p = rot * p * 2.0; - amp *= 0.5; - } - return v; - } - - // film grain - float grain(vec2 uv, float t) { - return fract(sin(dot(uv * (t + 1.0), vec2(12.9898, 78.233))) * 43758.5453); - } - - void main() { - vec2 uv = gl_FragCoord.xy / uResolution.xy; - float aspect = uResolution.x / uResolution.y; - vec2 p = uv; - p.x *= aspect; - - float t = uTime * 0.045 * (0.25 + uIntensity); - - // mouse parallax (gentle) - vec2 m = (uMouse - 0.5); - p += m * 0.12 * uIntensity; - - // domain warping for that liquid cloud feel - vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, -t))); - vec2 r = vec2( - fbm(p + 1.7 * q + vec2(8.3, 2.8) + 0.15 * t), - fbm(p + 1.7 * q + vec2(1.2, 6.5) - 0.12 * t) - ); - float f = fbm(p + 2.0 * r); - - // the brand spectrum, glowing ADDITIVELY out of a near-black base - vec3 glow = vec3(0.0); - glow += VIOLET * smoothstep(0.35, 0.95, f); - glow += BLUE * smoothstep(0.45, 1.0, r.x) * 0.9; - glow += ROSE * smoothstep(0.62, 1.05, q.y) * 0.5; - glow += MINT * smoothstep(0.66, 1.0, r.y) * 0.55; - - // concentrate the light into a soft diagonal wave band (echoes the asset) - float band = smoothstep(0.62, 0.0, abs((uv.x - uv.y) * 1.25 - 0.05 + 0.06 * sin(t * 2.0))); - float field = smoothstep(0.2, 0.85, f); - float energy = (0.35 + 0.65 * band) * field; - - vec3 col = BASE + glow * energy * (0.55 + 0.45 * uIntensity); - - // faint rainbow refraction along the band - vec3 spectrum = 0.5 + 0.5 * cos(6.2831 * (vec3(0.0, 0.33, 0.67) + (uv.x + uv.y) * 0.7)); - col += spectrum * band * 0.05 * (0.4 + uIntensity); - - // darken edges so content stays the focus - float vig = smoothstep(1.3, 0.2, distance(uv, vec2(0.5))); - col *= 0.55 + 0.45 * vig; - - // keep the base from washing out; clamp the glow softly - col = max(col, BASE * 0.6); - - // animated grain to kill banding + add texture - float g = grain(uv, floor(uTime * 12.0) * 0.5); - col += (g - 0.5) * 0.03; - - gl_FragColor = vec4(col, 1.0); - } -`; - -export default function Iridescence() { - const ref = useRef(null); - - useEffect(() => { - const canvas = ref.current; - if (!canvas) return; - - const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; - - let renderer: Renderer; - try { - renderer = new Renderer({ - canvas, - dpr: Math.min(window.devicePixelRatio, 1.75), - alpha: false, - antialias: false, - }); - } catch { - // WebGL unavailable — CSS fallback (set on the host) remains visible. - canvas.style.display = "none"; - return; - } - - const gl = renderer.gl; - gl.clearColor(0.031, 0.031, 0.047, 1); - - const uniforms = { - uTime: { value: 0 }, - uResolution: { value: new Vec2(1, 1) }, - uMouse: { value: new Vec2(0.5, 0.5) }, - uIntensity: { value: reduce ? 0 : 1 }, - }; - - const program = new Program(gl, { - vertex: VERT, - fragment: FRAG, - uniforms, - }); - const mesh = new Mesh(gl, { geometry: new Triangle(gl), program }); - - const resize = () => { - const w = window.innerWidth; - const h = window.innerHeight; - renderer.setSize(w, h); - uniforms.uResolution.value.set(gl.drawingBufferWidth, gl.drawingBufferHeight); - }; - resize(); - window.addEventListener("resize", resize); - - // smoothed pointer - const target = { x: 0.5, y: 0.5 }; - const onMove = (e: PointerEvent) => { - target.x = e.clientX / window.innerWidth; - target.y = 1 - e.clientY / window.innerHeight; - }; - window.addEventListener("pointermove", onMove, { passive: true }); - - let raf = 0; - let running = true; - const start = performance.now(); - - const loop = (now: number) => { - if (!running) return; - raf = requestAnimationFrame(loop); - uniforms.uTime.value = (now - start) / 1000; - // ease pointer - const m = uniforms.uMouse.value; - m.x += (target.x - m.x) * 0.04; - m.y += (target.y - m.y) * 0.04; - renderer.render({ scene: mesh }); - }; - - if (reduce) { - // render a single frame, then idle - uniforms.uTime.value = 18; - renderer.render({ scene: mesh }); - } else { - raf = requestAnimationFrame(loop); - } - - // pause when tab hidden (perf + battery) - const onVis = () => { - if (document.hidden) { - running = false; - cancelAnimationFrame(raf); - } else if (!reduce) { - running = true; - raf = requestAnimationFrame(loop); - } - }; - document.addEventListener("visibilitychange", onVis); - - return () => { - running = false; - cancelAnimationFrame(raf); - window.removeEventListener("resize", resize); - window.removeEventListener("pointermove", onMove); - document.removeEventListener("visibilitychange", onVis); - const ext = gl.getExtension("WEBGL_lose_context"); - ext?.loseContext(); - }; - }, []); - - return ( - - ); -} diff --git a/app/components/KineticHeadline.tsx b/app/components/KineticHeadline.tsx new file mode 100644 index 0000000..ff208f4 --- /dev/null +++ b/app/components/KineticHeadline.tsx @@ -0,0 +1,64 @@ +"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/KineticText.tsx b/app/components/KineticText.tsx deleted file mode 100644 index ebbfcf2..0000000 --- a/app/components/KineticText.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -/** - * KineticText — word/line masked reveal driven by ScrollTrigger. - * - * Splits the given text into words wrapped in overflow-hidden masks (manual - * spans — no premium SplitText plugin) and slides each word up from below as it - * enters the viewport, with a stagger. Renders the text as real, selectable DOM - * so it stays accessible and SEO-safe; animation only transforms (perf-friendly). - * - * With reduced-motion, the text simply appears (no transform). - */ - -import { createElement, useEffect, useRef, type ElementType } from "react"; -import { gsap } from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; - -gsap.registerPlugin(ScrollTrigger); - -type Props = { - text: string; - as?: ElementType; - className?: string; - /** Delay the whole reveal (s). */ - delay?: number; - /** Start animation on mount instead of on scroll (for the hero). */ - immediate?: boolean; - /** Mark a word index range as gradient-highlighted. */ - highlight?: [number, number]; -}; - -export default function KineticText({ - text, - as: Tag = "span", - className = "", - delay = 0, - immediate = false, - highlight, -}: Props) { - const ref = useRef(null); - const words = text.split(" "); - - useEffect(() => { - const el = ref.current; - if (!el) return; - const inner = gsap.utils.toArray(".ktext__in", el); - - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - gsap.set(inner, { yPercent: 0, opacity: 1 }); - return; - } - - const ctx = gsap.context(() => { - gsap.set(inner, { yPercent: 118 }); - const anim: gsap.TweenVars = { - yPercent: 0, - duration: 1.0, - ease: "power4.out", - stagger: 0.045, - delay, - }; - if (!immediate) { - anim.scrollTrigger = { trigger: el, start: "top 88%" }; - } - gsap.to(inner, anim); - }, el); - - return () => ctx.revert(); - }, [delay, immediate]); - - const children = words.map((w, i) => { - const hot = - highlight && i >= highlight[0] && i <= highlight[1] ? " ktext__word--grad" : ""; - return ( - - {w} - {i < words.length - 1 ? " " : ""} - - ); - }); - - return createElement(Tag, { ref, className: `ktext ${className}` }, children); -} diff --git a/app/components/Magnetic.tsx b/app/components/Magnetic.tsx deleted file mode 100644 index 342d279..0000000 --- a/app/components/Magnetic.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -/** - * Magnetic — wraps a single interactive child and pulls it toward the cursor - * within a radius, springing back on leave. Disabled for coarse pointers and - * reduced-motion. Purely visual; does not alter semantics of the child. - */ - -import { useEffect, useRef, type ReactNode } from "react"; -import { gsap } from "gsap"; - -export default function Magnetic({ - children, - strength = 0.4, - className = "", -}: { - children: ReactNode; - strength?: number; - className?: string; -}) { - const ref = useRef(null); - - useEffect(() => { - const el = ref.current; - if (!el) return; - if (window.matchMedia("(pointer: coarse)").matches) return; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - - const target = el.firstElementChild as HTMLElement | null; - if (!target) return; - - const xTo = gsap.quickTo(target, "x", { duration: 0.5, ease: "elastic.out(1, 0.4)" }); - const yTo = gsap.quickTo(target, "y", { duration: 0.5, ease: "elastic.out(1, 0.4)" }); - - const move = (e: MouseEvent) => { - const r = el.getBoundingClientRect(); - const mx = e.clientX - (r.left + r.width / 2); - const my = e.clientY - (r.top + r.height / 2); - xTo(mx * strength); - yTo(my * strength); - }; - const leave = () => { - xTo(0); - yTo(0); - }; - - el.addEventListener("mousemove", move); - el.addEventListener("mouseleave", leave); - return () => { - el.removeEventListener("mousemove", move); - el.removeEventListener("mouseleave", leave); - }; - }, [strength]); - - return ( - - {children} - - ); -} diff --git a/app/components/Marquee.tsx b/app/components/Marquee.tsx deleted file mode 100644 index 729d2ab..0000000 --- a/app/components/Marquee.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -/** - * ProofBar — "trusted by" band. A labelled, seamless looping marquee of the - * industries we grow, separated by the brand pill glyph. Pure CSS transform - * animation; pauses under reduced-motion via the stylesheet. Decorative track, - * but the label and items are real text. (Swap industry labels for greyscale - * client logos before launch.) - */ - -const items = [ - "E-commerce", - "B2B SaaS", - "Clinics", - "Professional services", - "Retail", - "Hospitality", -]; - -function Row() { - return ( - - ); -} - -export default function Marquee() { - return ( -
-

- Trusted by teams that care about the sales number -

-
-
- - -
-
-
- ); -} diff --git a/app/components/Metrics.tsx b/app/components/Metrics.tsx deleted file mode 100644 index 9d5fd12..0000000 --- a/app/components/Metrics.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -/** - * Metrics — four headline results with animated count-up on scroll-in. - * The ROAS metric is the single emerald-accented "signature" number. - */ - -import Reveal from "./Reveal"; -import CountUp from "./CountUp"; -import { metrics } from "../content"; - -export default function Metrics() { - return ( -
-
- -

The numbers

-

Results we can show you.

-
- -
- - {metrics.map((m) => ( -
-

- -

-

{m.label}

-
- ))} -
-
-
-
- ); -} diff --git a/app/components/PillMark.tsx b/app/components/PillMark.tsx deleted file mode 100644 index d8082e0..0000000 --- a/app/components/PillMark.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -/** - * PillMark — the brand imagotype as a living system. - * - * Reconstructs the 4-bar capsule grid (violet→blue gradient bar / ink bar + - * blue square / emerald bar) as inline SVG so each pill can be animated - * independently: it assembles on mount, then breathes. Used at hero scale and - * as a compact mark in dividers / footer. - * - * Decorative by default (aria-hidden). Pass `title` to expose it as an image. - */ - -import { useEffect, useRef } from "react"; -import { gsap } from "gsap"; - -type Props = { - className?: string; - /** Animate assembly on mount (hero). */ - animate?: boolean; - /** Continuous breathing after assembly. */ - breathe?: boolean; - title?: string; -}; - -export default function PillMark({ - className = "", - animate = false, - breathe = false, - title, -}: Props) { - const ref = useRef(null); - - useEffect(() => { - const el = ref.current; - if (!el) return; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - - const ctx = gsap.context(() => { - const bars = gsap.utils.toArray(".pillmark__bar"); - - if (animate) { - gsap.set(bars, { transformOrigin: "center center" }); - gsap.from(bars, { - scaleX: 0, - scaleY: 0.2, - opacity: 0, - duration: 1.1, - ease: "elastic.out(1, 0.75)", - stagger: { each: 0.09, from: "start" }, - delay: 0.2, - }); - } - - if (breathe) { - bars.forEach((bar, i) => { - gsap.to(bar, { - y: i % 2 === 0 ? "+=6" : "-=6", - duration: 2.6 + i * 0.25, - ease: "sine.inOut", - repeat: -1, - yoyo: true, - delay: 1.2 + i * 0.1, - }); - }); - } - }, el); - - return () => ctx.revert(); - }, [animate, breathe]); - - return ( - - {title ? {title} : null} - - - - - - - {/* top: full-width violet→blue gradient capsule */} - - {/* middle row: ink wide capsule + blue square capsule */} - - - {/* bottom: full-width emerald capsule */} - - - ); -} diff --git a/app/components/Process.tsx b/app/components/Process.tsx deleted file mode 100644 index 41d8c27..0000000 --- a/app/components/Process.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -/** - * Process — "The Feedback Loop". The intro column pins (sticky) while the four - * steps scroll past; each step lights up as it reaches the viewport center, and - * a serif count in the sticky column advances with it. This is the - * scrollytelling beat. Degrades to a plain stacked list under reduced-motion - * (all steps shown active, no pin behaviour) and on narrow screens. - */ - -import { useEffect, useRef, useState } from "react"; -import { gsap } from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; -import { processSteps } from "../content"; - -gsap.registerPlugin(ScrollTrigger); - -export default function Process() { - const root = useRef(null); - const [active, setActive] = useState(0); - - useEffect(() => { - const el = root.current; - if (!el) return; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setActive(processSteps.length - 1); - return; - } - - const ctx = gsap.context(() => { - gsap.utils.toArray(".pstep").forEach((step, i) => { - ScrollTrigger.create({ - trigger: step, - start: "top 60%", - end: "bottom 60%", - onToggle: (self) => { - if (self.isActive) setActive(i); - }, - }); - }); - }, el); - - return () => ctx.revert(); - }, []); - - return ( -
-
-
-

How it works

-

- The Feedback Loop. -

- -
- -
    - {processSteps.map((p, i) => ( -
  1. -
    - {p.n} -

    {p.name}

    -
    -

    {p.desc}

    -
  2. - ))} -
-
-
- ); -} diff --git a/app/components/Reveal.tsx b/app/components/Reveal.tsx index 4fba12a..eb507b5 100644 --- a/app/components/Reveal.tsx +++ b/app/components/Reveal.tsx @@ -1,54 +1,49 @@ "use client"; -import { useEffect, useRef, type ReactNode } from "react"; -import { gsap } from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; - -gsap.registerPlugin(ScrollTrigger); +import { useEffect, useRef } from "react"; /** - * Reveal — generic on-scroll entrance with personality (slide + soft clip), - * not a plain fade-up. With `stagger`, animates direct children in sequence. - * Respects reduced-motion (content shown immediately). + * 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. */ export default function Reveal({ children, + as: Tag = "div", className = "", - stagger = 0, - y = 44, + delay = 0, }: { - children: ReactNode; + children: React.ReactNode; + as?: keyof React.JSX.IntrinsicElements; className?: string; - stagger?: number; - y?: number; + delay?: number; }) { - const ref = useRef(null); + const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - - const targets = stagger > 0 ? Array.from(el.children) : [el]; - - const ctx = gsap.context(() => { - gsap.from(targets, { - y, - opacity: 0, - filter: "blur(8px)", - duration: 1.05, - ease: "power3.out", - stagger, - scrollTrigger: { trigger: el, start: "top 85%" }, - }); - }, el); - - return () => ctx.revert(); - }, [stagger, y]); + const io = new IntersectionObserver( + ([e]) => { + if (e.isIntersecting) { + el.classList.add("is-in"); + io.disconnect(); + } + }, + { threshold: 0.15, rootMargin: "0px 0px -8% 0px" } + ); + io.observe(el); + return () => io.disconnect(); + }, []); + const Comp = Tag as React.ElementType; return ( -
+ {children} -
+ ); } diff --git a/app/components/RevenueField.tsx b/app/components/RevenueField.tsx new file mode 100644 index 0000000..9e3c7da --- /dev/null +++ b/app/components/RevenueField.tsx @@ -0,0 +1,206 @@ +"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