feat: 'Revenue Terminal' — modern animated home (Motion+GSAP DrawSVG/MorphSVG, hero video, real interactions)
This commit is contained in:
parent
30bed043ec
commit
80f46b9780
28 changed files with 2043 additions and 1636 deletions
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<figure className="ba">
|
||||
<div
|
||||
className="ba__stage"
|
||||
ref={wrap}
|
||||
onPointerMove={(e) => e.buttons === 1 && drag(e.clientX)}
|
||||
onPointerDown={(e) => drag(e.clientX)}
|
||||
>
|
||||
{/* AFTER (full) — surging line */}
|
||||
<div className="ba__layer ba__after" aria-hidden="true">
|
||||
<Chart variant="after" />
|
||||
<span className="ba__tag ba__tag--after">{after}</span>
|
||||
</div>
|
||||
{/* BEFORE (clipped to slider) — flat, muted */}
|
||||
<div
|
||||
className="ba__layer ba__before"
|
||||
style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Chart variant="before" />
|
||||
<span className="ba__tag ba__tag--before">{before}</span>
|
||||
</div>
|
||||
|
||||
{/* divider + accessible control */}
|
||||
<div className="ba__divider" style={{ left: `${pos}%` }} aria-hidden="true">
|
||||
<span className="ba__handle">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M9 6 4 12l5 6M15 6l5 6-5 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<label className="sr-only" htmlFor={id}>
|
||||
Reveal results for {caption}: drag to compare before and after
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className="ba__range"
|
||||
type="range"
|
||||
min={2}
|
||||
max={98}
|
||||
value={pos}
|
||||
onChange={(e) => setPos(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="ba__cap">{caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<svg
|
||||
className={`ba__chart ba__chart--${variant}`}
|
||||
viewBox="0 0 240 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`g-${variant}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={after ? "var(--c-acid)" : "currentColor"} stopOpacity={after ? 0.35 : 0.12} />
|
||||
<stop offset="1" stopColor={after ? "var(--c-acid)" : "currentColor"} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={`${path} L 240 100 L 0 100 Z`} fill={`url(#g-${variant})`} stroke="none" />
|
||||
<path d={path} fill="none" stroke={after ? "var(--c-acid)" : "currentColor"} strokeWidth={after ? 2.5 : 1.5} strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
125
app/components/CaseCard.tsx
Normal file
125
app/components/CaseCard.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<motion.article
|
||||
ref={ref}
|
||||
className="case"
|
||||
onPointerMove={onMove}
|
||||
onPointerLeave={reset}
|
||||
style={reduce ? undefined : { rotateX: rx, rotateY: ry, transformPerspective: 1000 }}
|
||||
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
|
||||
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<div className="case__inner" style={{ ["--case-accent" as string]: data.accent }}>
|
||||
<div className="case__head">
|
||||
<span className="case__no">{String(index + 1).padStart(2, "0")}</span>
|
||||
<span className="case__tag">{data.tag}</span>
|
||||
</div>
|
||||
|
||||
{/* coded data visual — unique bars per case */}
|
||||
<div className="case__viz" aria-hidden="true">
|
||||
<div className="case__bars">
|
||||
{data.bars.map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="case__bar"
|
||||
style={{
|
||||
["--h" as string]: `${h}%`,
|
||||
["--d" as string]: `${i * 70}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<svg className="case__spark" viewBox="0 0 120 40" preserveAspectRatio="none">
|
||||
<polyline
|
||||
points="0,34 24,30 48,22 72,18 96,8 120,4"
|
||||
fill="none"
|
||||
stroke="var(--case-accent)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="case__body">
|
||||
<p className="case__problem">
|
||||
<span className="case__k">Before</span>
|
||||
{data.problem}
|
||||
</p>
|
||||
<p className="case__result display">{data.result}</p>
|
||||
<p className="case__how">{data.how}</p>
|
||||
</div>
|
||||
|
||||
<div className="case__metric">
|
||||
<span className="case__metric-num display">{data.metricNum}</span>
|
||||
<span className="case__metric-lab">{data.metricLabel}</span>
|
||||
</div>
|
||||
|
||||
{!reduce && (
|
||||
<motion.span
|
||||
className="case__glare"
|
||||
aria-hidden="true"
|
||||
style={{ background: glare }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLSpanElement>(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 (
|
||||
<span ref={ref}>
|
||||
{prefix}
|
||||
{val.toLocaleString("en-US", {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
})}
|
||||
{display}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const label = useRef<HTMLSpanElement>(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<HTMLElement>(
|
||||
"[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 (
|
||||
<div ref={ring} className="cursor" aria-hidden="true" data-active="0">
|
||||
<span className="cursor__cross" />
|
||||
<span ref={label} className="cursor__label" />
|
||||
<div aria-hidden="true" className="cursor-layer">
|
||||
<motion.div
|
||||
className={`cursor-ring ${hovering ? "is-hover" : ""} ${label ? "is-labelled" : ""}`}
|
||||
style={{ x: ringX, y: ringY }}
|
||||
>
|
||||
{label && <span className="cursor-ring__label">{label}</span>}
|
||||
</motion.div>
|
||||
<motion.div className="cursor-dot" style={{ x: dotX, y: dotY }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,27 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* FAQ accordion — accessible disclosure pattern.
|
||||
* - Real <button aria-expanded aria-controls> toggles each panel.
|
||||
* - Panel height animates via Motion (height auto), with a reduced-motion guard.
|
||||
* - Single-open behaviour; arrow rotates; keyboard + screen-reader friendly.
|
||||
* The full Q&A text is always in the DOM (good for AEO / FAQPage schema).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { faqs } from "../content";
|
||||
|
||||
/**
|
||||
* Accessible accordion built on native <button>. Uses aria-expanded +
|
||||
* aria-controls; panels are real content (always in DOM for SEO/AEO),
|
||||
* height-animated via grid-template-rows. One open at a time.
|
||||
*/
|
||||
export default function Faq() {
|
||||
const [open, setOpen] = useState<number | null>(0);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div className="faq">
|
||||
<ul className="faq">
|
||||
{faqs.map((f, i) => {
|
||||
const isOpen = open === i;
|
||||
return (
|
||||
<div className="faq__item" key={i}>
|
||||
<h3 className="faq__h">
|
||||
<li className={`faq__item ${isOpen ? "is-open" : ""}`} key={f.q}>
|
||||
<h3 className="faq__q">
|
||||
<button
|
||||
className="faq__btn"
|
||||
aria-expanded={isOpen}
|
||||
|
|
@ -25,27 +29,32 @@ export default function Faq() {
|
|||
id={`faq-btn-${i}`}
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
>
|
||||
<span className="faq__num">Q{i + 1}</span>
|
||||
<span className="faq__q">{f.q}</span>
|
||||
<span className="faq__sign" aria-hidden="true" data-open={isOpen}>
|
||||
<i /><i />
|
||||
<span>{f.q}</span>
|
||||
<span className="faq__icon" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<div
|
||||
id={`faq-panel-${i}`}
|
||||
role="region"
|
||||
aria-labelledby={`faq-btn-${i}`}
|
||||
className="faq__panel"
|
||||
data-open={isOpen}
|
||||
>
|
||||
<div className="faq__panel-inner">
|
||||
<p>{f.a}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
id={`faq-panel-${i}`}
|
||||
role="region"
|
||||
aria-labelledby={`faq-btn-${i}`}
|
||||
className="faq__panel"
|
||||
initial={reduce ? { height: "auto", opacity: 1 } : { height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={reduce ? { height: "auto", opacity: 1 } : { height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<p className="faq__a">{f.a}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
238
app/components/Hero.tsx
Normal file
238
app/components/Hero.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(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 (
|
||||
<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>
|
||||
|
||||
{/* ---- 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>
|
||||
{/* area under the curve */}
|
||||
<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"
|
||||
/>
|
||||
{/* the revenue line that draws itself */}
|
||||
<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" 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLHeadingElement>(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 (
|
||||
<h1 ref={ref} className={className} style={{ opacity: 0 }}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
53
app/components/Magnetic.tsx
Normal file
53
app/components/Magnetic.tsx
Normal file
|
|
@ -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<HTMLSpanElement>(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 (
|
||||
<motion.span
|
||||
ref={ref}
|
||||
className={className}
|
||||
onPointerMove={onMove}
|
||||
onPointerLeave={reset}
|
||||
style={{ x: sx, y: sy, display: "inline-block" }}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
133
app/components/ProcessLoop.tsx
Normal file
133
app/components/ProcessLoop.tsx
Normal file
|
|
@ -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<HTMLElement>(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<SVGPathElement>(".loop-glyph__path");
|
||||
if (!morph) return;
|
||||
const steps = gsap.utils.toArray<HTMLElement>(".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 (
|
||||
<section ref={root} id="process" className="loop frame" data-invert aria-labelledby="loop-h">
|
||||
<div className="loop-pin">
|
||||
<div className="wrap loop__inner">
|
||||
<header className="sec-head">
|
||||
<p className="kicker">
|
||||
<span className="kicker__dot" />
|
||||
The Feedback Loop
|
||||
</p>
|
||||
<h2 id="loop-h" className="display sec-head__title">
|
||||
How it works
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="loop__stage">
|
||||
{/* morphing glyph */}
|
||||
<div className="loop-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 100 100">
|
||||
<path className="loop-glyph__path" d={SHAPES[0]} fill="url(#loopGrad)" />
|
||||
<defs>
|
||||
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="50%" stopColor="#8b5cf6" />
|
||||
<stop offset="100%" stopColor="#10b981" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* steps + progress rail */}
|
||||
<div className="loop__steps">
|
||||
<div className="loop-rail" aria-hidden="true">
|
||||
<span className="loop-rail__fill" />
|
||||
</div>
|
||||
<ol>
|
||||
{processSteps.map((p) => (
|
||||
<li className="loop-step" key={p.n}>
|
||||
<span className="loop-step__n">{p.n}</span>
|
||||
<div>
|
||||
<h3 className="loop-step__name">{p.name}</h3>
|
||||
<p className="loop-step__desc">{p.desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLElement>(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 (
|
||||
<MotionTag
|
||||
className={className}
|
||||
variants={parent}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
|
||||
>
|
||||
{children}
|
||||
</MotionTag>
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
}
|
||||
|
||||
const Comp = Tag as React.ElementType;
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={`reveal ${className}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
<MotionTag
|
||||
className={className}
|
||||
initial={reduce ? { opacity: 1 } : { opacity: 0, y }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
|
||||
transition={{ duration: 0.7, delay: delay / 1000, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
</MotionTag>
|
||||
);
|
||||
}
|
||||
|
||||
/** A child item that participates in a parent <Reveal stagger>. */
|
||||
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 (
|
||||
<MotionTag className={className} variants={item}>
|
||||
{children}
|
||||
</MotionTag>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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 <div ref={wrap} className="revfield" aria-hidden="true" />;
|
||||
}
|
||||
94
app/components/Scoreboard.tsx
Normal file
94
app/components/Scoreboard.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* METRICS scoreboard.
|
||||
* - CountUp numbers animate on view (Motion).
|
||||
* - A second ANIMATED-SVG moment: a bar chart whose bars DRAW/grow + a baseline
|
||||
* that draws itself (GSAP DrawSVG) when the section scrolls in.
|
||||
* Reduced-motion: numbers jump to final, bars render at full height (CSS).
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "./gsap";
|
||||
import CountUp from "./CountUp";
|
||||
import { metrics } from "../content";
|
||||
|
||||
const BARS = [38, 56, 72, 64, 88, 96]; // decorative growth bars (0..100)
|
||||
|
||||
export default function Scoreboard() {
|
||||
const root = useRef<HTMLElement>(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 tl = gsap.timeline({
|
||||
scrollTrigger: { trigger: ".score__chart", start: "top 80%", once: true },
|
||||
});
|
||||
tl.from(".score-bar", {
|
||||
scaleY: 0,
|
||||
transformOrigin: "bottom",
|
||||
duration: 0.9,
|
||||
ease: "expo.out",
|
||||
stagger: 0.08,
|
||||
}).from(
|
||||
".score__baseline",
|
||||
{ drawSVG: "0%", duration: 0.9, ease: "power2.out" },
|
||||
"-=0.7"
|
||||
);
|
||||
}, el);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section ref={root} data-invert className="score frame" aria-labelledby="score-h">
|
||||
<div className="wrap">
|
||||
<header className="sec-head sec-head--center">
|
||||
<p className="kicker">
|
||||
<span className="kicker__dot" />
|
||||
The scoreboard — sample data
|
||||
</p>
|
||||
<h2 id="score-h" className="display sec-head__title">
|
||||
Numbers we report on
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<dl className="score__grid">
|
||||
{metrics.map((m) => (
|
||||
<div className="score__cell" key={m.label}>
|
||||
<dt className="sr-only">{m.label}</dt>
|
||||
<dd
|
||||
className={`score__num display ${"accent" in m && m.accent ? "is-accent" : ""}`}
|
||||
>
|
||||
<CountUp
|
||||
to={m.value}
|
||||
prefix={"prefix" in m ? m.prefix : ""}
|
||||
suffix={m.suffix}
|
||||
decimals={"decimals" in m ? m.decimals : 0}
|
||||
/>
|
||||
</dd>
|
||||
<p className="score__lab" aria-hidden="true">
|
||||
{m.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{/* decorative animated bar chart */}
|
||||
<div className="score__chart" aria-hidden="true">
|
||||
<div className="score__bars">
|
||||
{BARS.map((h, i) => (
|
||||
<span key={i} className="score-bar" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
<svg className="score__axis" viewBox="0 0 600 8" preserveAspectRatio="none">
|
||||
<line className="score__baseline" x1="0" y1="4" x2="600" y2="4" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,104 +1,116 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
/**
|
||||
* Sticky header.
|
||||
* - Condenses (smaller, blurred, bordered) after the hero via scroll listener.
|
||||
* - Top scroll-progress bar reads the --scroll-progress CSS var fed by Lenis.
|
||||
* - Mobile: accessible disclosure menu (real <button aria-expanded>, Esc to
|
||||
* close, focus returns to the toggle, body scroll locked while open).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Magnetic from "./Magnetic";
|
||||
import { SITE } from "../content";
|
||||
|
||||
const NAV = [
|
||||
{ href: "#services", label: "Services" },
|
||||
{ href: "#work", label: "Work" },
|
||||
{ href: "#about", label: "About" },
|
||||
{ href: "#process", label: "About" },
|
||||
{ href: "#faq", label: "FAQ" },
|
||||
];
|
||||
|
||||
/** Masthead-style sticky header with a mono "ticker bug" logo. */
|
||||
export default function SiteHeader() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||
const onScroll = () => setScrolled(window.scrollY > 40);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// lock body + escape to close mobile nav
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? "hidden" : "";
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
if (!open) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
toggleRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<header className={`masthead ${scrolled ? "is-scrolled" : ""}`}>
|
||||
<div className="masthead__bar frame">
|
||||
<a href="#main" className="logo" aria-label="Feedback Studios — home">
|
||||
<span className="logo__bug" aria-hidden="true">
|
||||
FS
|
||||
<header className={`site-head ${scrolled ? "is-scrolled" : ""}`}>
|
||||
<div className="progress-bar" aria-hidden="true" />
|
||||
<div className="site-head__inner">
|
||||
<Link href="#main" className="brand" aria-label={`${SITE.name} — home`}>
|
||||
<span className="brand__mark" aria-hidden="true">
|
||||
<span className="brand__dot" />
|
||||
</span>
|
||||
<span className="logo__name">Feedback Studios</span>
|
||||
<span className="logo__tag" aria-hidden="true">
|
||||
EST. ’26 · REV
|
||||
</span>
|
||||
</a>
|
||||
<span className="brand__name">Feedback Studios</span>
|
||||
</Link>
|
||||
|
||||
<nav className="masthead__nav" aria-label="Primary">
|
||||
<nav className="site-nav" aria-label="Primary">
|
||||
<ul>
|
||||
{NAV.map((n) => (
|
||||
<li key={n.href}>
|
||||
<a href={n.href}>{n.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<a
|
||||
className="btn btn--accent masthead__cta"
|
||||
href={SITE.booking}
|
||||
data-cursor="Let’s talk"
|
||||
>
|
||||
Get a growth audit
|
||||
</a>
|
||||
|
||||
<button
|
||||
className="masthead__burger"
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-nav"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<span className="sr-only">{open ? "Close menu" : "Open menu"}</span>
|
||||
<span className="masthead__burger-box" data-open={open} aria-hidden="true">
|
||||
<i /><i /><i />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="masthead__rule" aria-hidden="true" />
|
||||
|
||||
{/* mobile drawer */}
|
||||
<div id="mobile-nav" className="drawer" data-open={open}>
|
||||
<nav aria-label="Mobile">
|
||||
<ul>
|
||||
{NAV.map((n, i) => (
|
||||
<li key={n.href} style={{ transitionDelay: `${i * 50 + 60}ms` }}>
|
||||
<a href={n.href} onClick={() => setOpen(false)}>
|
||||
<span className="drawer__idx">0{i + 1}</span>
|
||||
<a href={n.href} data-cursor="">
|
||||
{n.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<a
|
||||
className="btn btn--accent"
|
||||
href={SITE.booking}
|
||||
onClick={() => setOpen(false)}
|
||||
|
||||
<div className="site-head__cta">
|
||||
<Magnetic strength={0.5}>
|
||||
<Link href={SITE.booking} className="btn btn--accent btn--sm" data-cursor="Let's talk">
|
||||
Get a growth audit
|
||||
</Link>
|
||||
</Magnetic>
|
||||
</div>
|
||||
|
||||
<button
|
||||
ref={toggleRef}
|
||||
className={`burger ${open ? "is-open" : ""}`}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
Get a growth audit
|
||||
</a>
|
||||
<span /><span /><span />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="mobile-menu" className={`mobile-menu ${open ? "is-open" : ""}`} hidden={!open}>
|
||||
<nav aria-label="Mobile">
|
||||
<ul>
|
||||
{NAV.map((n) => (
|
||||
<li key={n.href}>
|
||||
<a href={n.href} onClick={() => setOpen(false)}>
|
||||
{n.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link
|
||||
href={SITE.booking}
|
||||
className="btn btn--accent"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Get a growth audit
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,34 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Lenis smooth scroll wired into GSAP ScrollTrigger.
|
||||
* - Lenis drives the rAF loop; ScrollTrigger.update() runs on every Lenis tick
|
||||
* so pinned/scrubbed animations stay in lockstep with the smoothed scroll.
|
||||
* - Disabled entirely when prefers-reduced-motion is set (native scroll).
|
||||
* - Exposes a scroll-progress value on :root for the top progress bar (CSS).
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import Lenis from "lenis";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { gsap, ScrollTrigger } from "./gsap";
|
||||
|
||||
/**
|
||||
* Lenis smooth scroll wired into GSAP's ScrollTrigger so scroll-driven
|
||||
* animation stays in sync. Disabled entirely under reduced-motion.
|
||||
*/
|
||||
export default function SmoothScroll() {
|
||||
useEffect(() => {
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduce) return;
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
// Always keep the CSS progress var fed (cheap), even without Lenis.
|
||||
const setProgress = (p: number) =>
|
||||
document.documentElement.style.setProperty("--scroll-progress", String(p));
|
||||
|
||||
if (reduce) {
|
||||
const onScroll = () => {
|
||||
const h = document.documentElement;
|
||||
const max = h.scrollHeight - h.clientHeight;
|
||||
setProgress(max > 0 ? h.scrollTop / max : 0);
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}
|
||||
|
||||
const lenis = new Lenis({
|
||||
duration: 1.1,
|
||||
|
|
@ -22,32 +36,18 @@ export default function SmoothScroll() {
|
|||
smoothWheel: true,
|
||||
});
|
||||
|
||||
lenis.on("scroll", ScrollTrigger.update);
|
||||
lenis.on("scroll", (e: { progress: number }) => {
|
||||
ScrollTrigger.update();
|
||||
setProgress(e.progress);
|
||||
});
|
||||
|
||||
const raf = (time: number) => lenis.raf(time * 1000);
|
||||
gsap.ticker.add(raf);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
// anchor links route through Lenis
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const a = (e.target as HTMLElement)?.closest('a[href^="#"]') as
|
||||
| HTMLAnchorElement
|
||||
| null;
|
||||
if (!a) return;
|
||||
const id = a.getAttribute("href");
|
||||
if (!id || id === "#") return;
|
||||
const el = document.querySelector(id);
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
lenis.scrollTo(el as HTMLElement, { offset: -80 });
|
||||
};
|
||||
document.addEventListener("click", onClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", onClick);
|
||||
gsap.ticker.remove(raf);
|
||||
lenis.destroy();
|
||||
ScrollTrigger.getAll().forEach((t) => t.kill());
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Item = { v: string; up?: boolean; label: string };
|
||||
|
||||
/**
|
||||
* A Bloomberg-style revenue ticker tape. Infinite CSS marquee with live-
|
||||
* feeling deltas. The visible tape is aria-hidden (decorative motion); a
|
||||
* single screen-reader summary conveys the same facts statically.
|
||||
*
|
||||
* Numbers are illustrative SAMPLES (matches the content note).
|
||||
*/
|
||||
const ITEMS: Item[] = [
|
||||
{ v: "+$2.41M", up: true, label: "Q ARR added" },
|
||||
{ v: "3.8×", up: true, label: "ROAS" },
|
||||
{ v: "−34%", up: true, label: "CPA" },
|
||||
{ v: "+217%", up: true, label: "Demo reqs" },
|
||||
{ v: "+183%", up: true, label: "Organic" },
|
||||
{ v: "+128", up: true, label: "Bookings/mo" },
|
||||
{ v: "+52%", up: true, label: "ROAS 90d" },
|
||||
{ v: "92%", up: true, label: "Retention" },
|
||||
{ v: "+$40M", up: true, label: "Client revenue" },
|
||||
];
|
||||
|
||||
export default function Ticker() {
|
||||
const [tick, setTick] = useState(0);
|
||||
const reduce = useRef(false);
|
||||
|
||||
// gentle "last digit flicker" to feel live (skipped on reduced motion)
|
||||
useEffect(() => {
|
||||
reduce.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduce.current) return;
|
||||
const id = setInterval(() => setTick((t) => t + 1), 2600);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const Row = ({ aria }: { aria: boolean }) => (
|
||||
<ul className="ticker__row" aria-hidden={aria ? undefined : true}>
|
||||
{ITEMS.map((it, i) => (
|
||||
<li className="ticker__item" key={i}>
|
||||
<span className="ticker__arr" aria-hidden="true">
|
||||
{it.up ? "▲" : "▼"}
|
||||
</span>
|
||||
<span className="ticker__val">{it.v}</span>
|
||||
<span className="ticker__lab">{it.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ticker" data-tick={tick % 2}>
|
||||
{/* SR-only static summary of the same data */}
|
||||
<p className="sr-only">
|
||||
Sample results across recent client programs: 3.8 times average return
|
||||
on ad spend, plus 217 percent qualified demo requests, plus 183 percent
|
||||
organic traffic, 92 percent client retention, and over 40 million
|
||||
dollars in client revenue generated.
|
||||
</p>
|
||||
<div className="ticker__track" aria-hidden="true">
|
||||
<Row aria />
|
||||
<Row aria />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
app/components/VelocityMarquee.tsx
Normal file
80
app/components/VelocityMarquee.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Infinite marquee whose speed + skew react to scroll velocity.
|
||||
* - Base drift via Motion `animationFrame` + a wrapped motion value.
|
||||
* - Scroll velocity (useVelocity over scrollY) adds directional boost + skew,
|
||||
* so the tape "leans" as you fling the page — a signature kinetic touch.
|
||||
* - Two copies of the content guarantee a seamless loop.
|
||||
* - prefers-reduced-motion: static row, no transform churn.
|
||||
*/
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
motion,
|
||||
useScroll,
|
||||
useVelocity,
|
||||
useSpring,
|
||||
useTransform,
|
||||
useMotionValue,
|
||||
useAnimationFrame,
|
||||
useReducedMotion,
|
||||
wrap,
|
||||
} from "motion/react";
|
||||
|
||||
export default function VelocityMarquee({
|
||||
items,
|
||||
baseVelocity = 60,
|
||||
}: {
|
||||
items: string[];
|
||||
baseVelocity?: number;
|
||||
}) {
|
||||
const reduce = useReducedMotion();
|
||||
const baseX = useMotionValue(0);
|
||||
const { scrollY } = useScroll();
|
||||
const scrollVelocity = useVelocity(scrollY);
|
||||
const smoothVelocity = useSpring(scrollVelocity, { damping: 50, stiffness: 400 });
|
||||
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 4], { clamp: false });
|
||||
const skew = useTransform(smoothVelocity, [-2000, 0, 2000], [-6, 0, 6], { clamp: true });
|
||||
|
||||
// 50% because we render the content twice; wrap keeps it seamless.
|
||||
const x = useTransform(baseX, (v) => `${wrap(-50, 0, v)}%`);
|
||||
const directionFactor = useRef(1);
|
||||
|
||||
useAnimationFrame((_, delta) => {
|
||||
if (reduce) return;
|
||||
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
|
||||
const vf = velocityFactor.get();
|
||||
if (vf < 0) directionFactor.current = -1;
|
||||
else if (vf > 0) directionFactor.current = 1;
|
||||
moveBy += directionFactor.current * moveBy * vf;
|
||||
baseX.set(baseX.get() + moveBy / 14);
|
||||
});
|
||||
|
||||
const Row = (
|
||||
<>
|
||||
{items.map((it, i) => (
|
||||
<span className="marq__item" key={i}>
|
||||
{it}
|
||||
<span className="marq__star" aria-hidden="true">✱</span>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
if (reduce) {
|
||||
return (
|
||||
<div className="marq" aria-hidden="true">
|
||||
<div className="marq__track marq__track--static">{Row}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div className="marq" style={{ skewX: skew }} aria-hidden="true">
|
||||
<motion.div className="marq__track" style={{ x }}>
|
||||
{Row}
|
||||
{Row}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
19
app/components/gsap.ts
Normal file
19
app/components/gsap.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Centralised GSAP setup. Registers the (now-free) premium plugins ONCE,
|
||||
* client-side only. Import { gsap, ScrollTrigger, ... } from here so every
|
||||
* component shares the same registration and we never double-register.
|
||||
*
|
||||
* GSAP 3.15 ships DrawSVG / MorphSVG / SplitText / ScrollTrigger in the
|
||||
* package (April 2025: all club plugins went free).
|
||||
*/
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
|
||||
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText);
|
||||
}
|
||||
|
||||
export { gsap, ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText };
|
||||
|
|
@ -56,36 +56,40 @@ export const metrics = [
|
|||
{ value: 92, suffix: "%", label: "client retention" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Case studies. Visuals are now CODED data charts (unique `bars` per case +
|
||||
* `accent`), not the rejected repetitive raster images — so no two look alike.
|
||||
*/
|
||||
export const cases = [
|
||||
{
|
||||
img: "/assets/case-1.png",
|
||||
alt: "Glossy 3D capsule cluster in brand violet, blue and emerald, representing the e-commerce fashion case study",
|
||||
tag: "E-commerce · Fashion",
|
||||
problem: "Rising ad costs were eating the margin.",
|
||||
result: "−34% CPA and +52% ROAS in 90 days",
|
||||
how: "Meta + Google Shopping restructure.",
|
||||
metricNum: "+52%",
|
||||
metricLabel: "ROAS in 90 days",
|
||||
bars: [28, 40, 36, 58, 72, 92],
|
||||
accent: "#8b5cf6",
|
||||
},
|
||||
{
|
||||
img: "/assets/case-2.png",
|
||||
alt: "Suspended glossy capsules in brand colors, representing the B2B SaaS case study",
|
||||
tag: "B2B SaaS",
|
||||
problem: "Plenty of traffic, no pipeline.",
|
||||
result: "+217% qualified demo requests in 6 months",
|
||||
how: "SEO + content + LinkedIn.",
|
||||
metricNum: "+217%",
|
||||
metricLabel: "demo requests",
|
||||
bars: [18, 22, 30, 48, 70, 96],
|
||||
accent: "#3b82f6",
|
||||
},
|
||||
{
|
||||
img: "/assets/case-3.png",
|
||||
alt: "Cascade of glossy capsules in brand colors, representing the aesthetic clinic case study",
|
||||
tag: "Aesthetic clinic",
|
||||
problem: "Empty calendar despite the ad spend.",
|
||||
result: "+128 booked consultations a month",
|
||||
how: "Paid + landing-page rebuild.",
|
||||
metricNum: "+128",
|
||||
metricLabel: "consultations / month",
|
||||
bars: [24, 34, 30, 52, 66, 88],
|
||||
accent: "#10b981",
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
1537
app/globals.css
1537
app/globals.css
File diff suppressed because it is too large
Load diff
|
|
@ -102,7 +102,7 @@ export default function RootLayout({
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="theme-color" content="#f4f1ea" />
|
||||
<meta name="theme-color" content="#07070b" />
|
||||
</head>
|
||||
<body>
|
||||
<StructuredData />
|
||||
|
|
|
|||
357
app/page.tsx
357
app/page.tsx
|
|
@ -1,24 +1,34 @@
|
|||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
SITE,
|
||||
services,
|
||||
metrics,
|
||||
cases,
|
||||
processSteps,
|
||||
testimonials,
|
||||
} from "./content";
|
||||
|
||||
import SiteHeader from "./components/SiteHeader";
|
||||
import RevenueField from "./components/RevenueField";
|
||||
import KineticHeadline from "./components/KineticHeadline";
|
||||
import Ticker from "./components/Ticker";
|
||||
import CountUp from "./components/CountUp";
|
||||
import Reveal from "./components/Reveal";
|
||||
import BeforeAfter from "./components/BeforeAfter";
|
||||
import Hero from "./components/Hero";
|
||||
import VelocityMarquee from "./components/VelocityMarquee";
|
||||
import Reveal, { RevealItem } from "./components/Reveal";
|
||||
import Scoreboard from "./components/Scoreboard";
|
||||
import CaseCard, { type CaseData } from "./components/CaseCard";
|
||||
import ProcessLoop from "./components/ProcessLoop";
|
||||
import Faq from "./components/Faq";
|
||||
import Magnetic from "./components/Magnetic";
|
||||
|
||||
const PROOF = [
|
||||
const TAPE = [
|
||||
"Revenue, not vanity metrics",
|
||||
"Paid",
|
||||
"SEO",
|
||||
"Content",
|
||||
"Social",
|
||||
"+52% ROAS",
|
||||
"+217% demos",
|
||||
"+128 booked/mo",
|
||||
"Reported every month",
|
||||
];
|
||||
|
||||
const INDUSTRIES = [
|
||||
"E-commerce",
|
||||
"B2B SaaS",
|
||||
"Clinics",
|
||||
|
|
@ -27,142 +37,58 @@ const PROOF = [
|
|||
"Marketplaces",
|
||||
];
|
||||
|
||||
const beforeAfter = [
|
||||
{ before: "Flat sales", after: "+52% ROAS" },
|
||||
{ before: "No pipeline", after: "+217% demos" },
|
||||
{ before: "Empty calendar", after: "+128 booked/mo" },
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
|
||||
<main id="main">
|
||||
{/* =========================================================
|
||||
HERO — the masthead front page
|
||||
========================================================= */}
|
||||
<section className="hero frame" aria-labelledby="hero-h1">
|
||||
<div className="hero__field">
|
||||
<RevenueField />
|
||||
</div>
|
||||
<Hero />
|
||||
|
||||
{/* dateline / edition strip */}
|
||||
<div className="hero__dateline">
|
||||
<span>The Revenue Edition</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>No. 01</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>Digital marketing agency</span>
|
||||
</div>
|
||||
<div className="rule rule--strong" />
|
||||
{/* ===== KINETIC TAPE — scroll-velocity marquee ===== */}
|
||||
<section className="tape" aria-label="What we do, at a glance">
|
||||
<VelocityMarquee items={TAPE} />
|
||||
</section>
|
||||
|
||||
<div className="hero__grid">
|
||||
<div className="hero__lead">
|
||||
<p className="kicker">
|
||||
<span className="kicker__dot" />
|
||||
Marketing, priced in revenue
|
||||
</p>
|
||||
<KineticHeadline className="display hero__h1">
|
||||
<span id="hero-h1">
|
||||
Marketing that grows your <span className="cash">revenue.</span>
|
||||
</span>
|
||||
</KineticHeadline>
|
||||
</div>
|
||||
|
||||
<div className="hero__side">
|
||||
<p className="hero__sub">
|
||||
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.
|
||||
</p>
|
||||
<div className="hero__cta">
|
||||
<Link
|
||||
href={SITE.booking}
|
||||
className="btn btn--accent"
|
||||
data-cursor="Book it"
|
||||
>
|
||||
Get your growth audit
|
||||
</Link>
|
||||
<a href="#work" className="btn btn--ghost">
|
||||
See the results <span className="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* hero stat slab */}
|
||||
<div className="hero__slab">
|
||||
<div className="hero__stat">
|
||||
<span className="hero__stat-num display">$40M+</span>
|
||||
<span className="hero__stat-lab">in client revenue generated</span>
|
||||
</div>
|
||||
<div className="hero__stat">
|
||||
<span className="hero__stat-num display">50+</span>
|
||||
<span className="hero__stat-lab">brands grown</span>
|
||||
</div>
|
||||
<div className="hero__stat hero__stat--live">
|
||||
<span className="hero__live" aria-hidden="true" />
|
||||
<span className="hero__stat-lab">
|
||||
Live revenue feed — sample data
|
||||
{/* ===== POSITIONING / MANIFESTO ===== */}
|
||||
<section id="manifesto" className="manifesto frame" aria-labelledby="man-h">
|
||||
<div className="wrap manifesto__wrap">
|
||||
<Reveal as="p" className="kicker">
|
||||
<span className="kicker__dot" />
|
||||
The problem with most agencies
|
||||
</Reveal>
|
||||
<Reveal as="p" className="manifesto__lead display" delay={80}>
|
||||
<span id="man-h">
|
||||
Most budgets buy <span className="strike">activity</span>, not
|
||||
outcomes. Dashboards fill with impressions while the sales number{" "}
|
||||
<span className="grad">sits still.</span>
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal as="p" className="manifesto__body" delay={180}>
|
||||
We started Feedback Studios to fix that. Every campaign we run is
|
||||
built to move revenue, and we report on it the way your CFO would.
|
||||
No vanity metrics. No mystery. Just the number that pays the bills.
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* =========================================================
|
||||
TICKER — kinetic social proof tape
|
||||
========================================================= */}
|
||||
<section data-invert className="tape" aria-label="Sample results ticker">
|
||||
<Ticker />
|
||||
</section>
|
||||
|
||||
{/* =========================================================
|
||||
SOCIAL PROOF / industries
|
||||
========================================================= */}
|
||||
<section className="proof frame" aria-label="Who we work with">
|
||||
<div className="wrap proof__wrap">
|
||||
{/* ===== INDUSTRIES / SOCIAL PROOF ===== */}
|
||||
<section className="proof" aria-label="Who we work with">
|
||||
<div className="wrap">
|
||||
<p className="kicker proof__label">
|
||||
Trusted by teams that care about the sales number
|
||||
</p>
|
||||
<ul className="proof__list">
|
||||
{PROOF.map((p) => (
|
||||
<li key={p} className="proof__item">
|
||||
<Reveal as="ul" className="proof__list" stagger={0.06}>
|
||||
{INDUSTRIES.map((p) => (
|
||||
<RevealItem as="li" className="proof__item" key={p}>
|
||||
{p}
|
||||
</li>
|
||||
</RevealItem>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* =========================================================
|
||||
POSITIONING / PROBLEM — the manifesto spread
|
||||
========================================================= */}
|
||||
<section data-invert className="manifesto frame" aria-labelledby="man-h">
|
||||
<div className="wrap manifesto__wrap">
|
||||
<p className="kicker">
|
||||
<span className="kicker__dot" />
|
||||
The problem with most agencies
|
||||
</p>
|
||||
<Reveal as="p" className="manifesto__lead display" >
|
||||
<span id="man-h">
|
||||
Most budgets buy <span className="strike">activity</span>, not
|
||||
outcomes. Dashboards fill up with impressions while the sales
|
||||
number <span className="cash">sits still.</span>
|
||||
</span>
|
||||
</Reveal>
|
||||
<Reveal as="p" className="manifesto__body" delay={120}>
|
||||
We started Feedback Studios to fix that. Every campaign we run is
|
||||
built to move revenue, and we report on it the way your CFO
|
||||
would. No vanity metrics. No mystery. Just the number that pays
|
||||
the bills.
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* =========================================================
|
||||
SERVICES — the index / ledger
|
||||
========================================================= */}
|
||||
{/* ===== SERVICES — interactive ledger ===== */}
|
||||
<section id="services" className="services frame" aria-labelledby="svc-h">
|
||||
<div className="wrap">
|
||||
<header className="sec-head">
|
||||
|
|
@ -175,55 +101,27 @@ export default function Page() {
|
|||
</h2>
|
||||
</header>
|
||||
|
||||
<ol className="ledger">
|
||||
<Reveal as="ol" className="ledger" stagger={0.07}>
|
||||
{services.map((s, i) => (
|
||||
<li key={s.id} className="ledger__row" data-cursor="">
|
||||
<span className="ledger__no">{String(i + 1).padStart(2, "0")}</span>
|
||||
<h3 className="ledger__name display">{s.name}</h3>
|
||||
<p className="ledger__desc">{s.desc}</p>
|
||||
<span className="ledger__arrow" aria-hidden="true">↗</span>
|
||||
</li>
|
||||
<RevealItem as="li" key={s.id}>
|
||||
<a href={SITE.booking} className="ledger__row" data-cursor="Scope it">
|
||||
<span className="ledger__no">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h3 className="ledger__name display">{s.name}</h3>
|
||||
<p className="ledger__desc">{s.desc}</p>
|
||||
<span className="ledger__arrow" aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</RevealItem>
|
||||
))}
|
||||
</ol>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* =========================================================
|
||||
METRICS — count-up scoreboard (inverted)
|
||||
========================================================= */}
|
||||
<section data-invert className="score frame" aria-labelledby="score-h">
|
||||
<div className="wrap">
|
||||
<header className="sec-head sec-head--center">
|
||||
<p className="kicker">
|
||||
<span className="kicker__dot" />
|
||||
The scoreboard — sample data
|
||||
</p>
|
||||
<h2 id="score-h" className="display sec-head__title">
|
||||
Numbers we report on
|
||||
</h2>
|
||||
</header>
|
||||
<dl className="score__grid">
|
||||
{metrics.map((m) => (
|
||||
<div className="score__cell" key={m.label}>
|
||||
<dt className="sr-only">{m.label}</dt>
|
||||
<dd className={`score__num display ${"accent" in m && m.accent ? "is-accent" : ""}`}>
|
||||
<CountUp
|
||||
to={m.value}
|
||||
prefix={"prefix" in m ? m.prefix : ""}
|
||||
suffix={m.suffix}
|
||||
decimals={"decimals" in m ? m.decimals : 0}
|
||||
/>
|
||||
</dd>
|
||||
<p className="score__lab" aria-hidden="true">{m.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
{/* ===== METRICS scoreboard (animated SVG bars + count-ups) ===== */}
|
||||
<Scoreboard />
|
||||
|
||||
{/* =========================================================
|
||||
CASE STUDIES — proof spreads w/ before/after sliders
|
||||
========================================================= */}
|
||||
{/* ===== CASE STUDIES — 3D tilt + coded charts ===== */}
|
||||
<section id="work" className="work frame" aria-labelledby="work-h">
|
||||
<div className="wrap">
|
||||
<header className="sec-head">
|
||||
|
|
@ -236,94 +134,35 @@ export default function Page() {
|
|||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="work__list">
|
||||
<div className="work__grid">
|
||||
{cases.map((c, i) => (
|
||||
<article className="case" key={c.tag}>
|
||||
<div className="case__index">
|
||||
<span className="case__no">{String(i + 1).padStart(2, "0")}</span>
|
||||
<span className="case__tag">{c.tag}</span>
|
||||
</div>
|
||||
|
||||
<div className="case__body">
|
||||
<p className="case__problem">
|
||||
<span className="case__problem-k">Before:</span> {c.problem}
|
||||
</p>
|
||||
<p className="case__result display">{c.result}</p>
|
||||
<p className="case__how">{c.how}</p>
|
||||
</div>
|
||||
|
||||
<div className="case__visual">
|
||||
<BeforeAfter
|
||||
before={beforeAfter[i].before}
|
||||
after={beforeAfter[i].after}
|
||||
caption={c.tag}
|
||||
/>
|
||||
{/* optional brand asset, lazy + sized to avoid CLS */}
|
||||
<Image
|
||||
src={c.img}
|
||||
alt={c.alt}
|
||||
width={1200}
|
||||
height={896}
|
||||
className="case__img"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 900px) 90vw, 40vw"
|
||||
/>
|
||||
<div className="case__metric">
|
||||
<span className="case__metric-num display">{c.metricNum}</span>
|
||||
<span className="case__metric-lab">{c.metricLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<CaseCard key={c.tag} data={c as unknown as CaseData} index={i} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="work__cta">
|
||||
<a href={SITE.booking} className="btn btn--ghost">
|
||||
View all work <span className="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
<Magnetic strength={0.3}>
|
||||
<a href={SITE.booking} className="btn btn--ghost" data-cursor="See more">
|
||||
View all work <span className="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</Magnetic>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* =========================================================
|
||||
PROCESS — The Feedback Loop
|
||||
========================================================= */}
|
||||
<section data-invert id="about" className="loop frame" aria-labelledby="loop-h">
|
||||
<div className="wrap">
|
||||
<header className="sec-head">
|
||||
<p className="kicker">
|
||||
<span className="kicker__dot" />
|
||||
The Feedback Loop
|
||||
</p>
|
||||
<h2 id="loop-h" className="display sec-head__title">
|
||||
How it works
|
||||
</h2>
|
||||
</header>
|
||||
{/* ===== PROCESS — pinned scroll + morphing SVG ===== */}
|
||||
<ProcessLoop />
|
||||
|
||||
<ol className="loop__grid">
|
||||
{processSteps.map((p) => (
|
||||
<li className="loop__step" key={p.n}>
|
||||
<span className="loop__n display">{p.n}</span>
|
||||
<h3 className="loop__name">{p.name}</h3>
|
||||
<p className="loop__desc">{p.desc}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* =========================================================
|
||||
TESTIMONIALS — pull quotes
|
||||
========================================================= */}
|
||||
{/* ===== TESTIMONIALS ===== */}
|
||||
<section className="quotes frame" aria-label="What clients say">
|
||||
<div className="wrap quotes__grid">
|
||||
{testimonials.map((t, i) => (
|
||||
<Reveal as="figure" className="quote" key={i} delay={i * 100}>
|
||||
<blockquote className="quote__text display">
|
||||
“{t.quote}”
|
||||
</blockquote>
|
||||
<Reveal as="figure" className="quote" key={i} delay={i * 120}>
|
||||
<span className="quote__mark display" aria-hidden="true">“</span>
|
||||
<blockquote className="quote__text display">{t.quote}</blockquote>
|
||||
<figcaption className="quote__by">
|
||||
<span className="quote__rule" aria-hidden="true" /> {t.by}
|
||||
<span className="quote__rule" aria-hidden="true" />
|
||||
{t.by}
|
||||
</figcaption>
|
||||
</Reveal>
|
||||
))}
|
||||
|
|
@ -337,9 +176,7 @@ export default function Page() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* =========================================================
|
||||
FAQ
|
||||
========================================================= */}
|
||||
{/* ===== FAQ ===== */}
|
||||
<section id="faq" data-invert className="faq-sec frame" aria-labelledby="faq-h">
|
||||
<div className="wrap faq-sec__wrap">
|
||||
<header className="sec-head">
|
||||
|
|
@ -355,26 +192,28 @@ export default function Page() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* =========================================================
|
||||
FINAL CTA
|
||||
========================================================= */}
|
||||
{/* ===== FINAL CTA ===== */}
|
||||
<section className="final frame" aria-labelledby="final-h">
|
||||
<div className="wrap final__wrap">
|
||||
<p className="kicker">
|
||||
<span className="kicker__dot" />
|
||||
The bottom line
|
||||
</p>
|
||||
<h2 id="final-h" className="display final__h">
|
||||
Ready to <span className="cash">grow?</span>
|
||||
</h2>
|
||||
<Reveal as="header">
|
||||
<h2 id="final-h" className="display final__h">
|
||||
Ready to <span className="grad">grow?</span>
|
||||
</h2>
|
||||
</Reveal>
|
||||
<p className="final__sub">
|
||||
No long contracts. No vanity reports. Marketing you can measure in
|
||||
sales.
|
||||
</p>
|
||||
<div className="final__cta">
|
||||
<Link href={SITE.booking} className="btn btn--accent" data-cursor="Book a call">
|
||||
Book a call
|
||||
</Link>
|
||||
<Magnetic strength={0.4}>
|
||||
<Link href={SITE.booking} className="btn btn--accent" data-cursor="Book a call">
|
||||
Book a call
|
||||
</Link>
|
||||
</Magnetic>
|
||||
<a href={`mailto:${SITE.email}`} className="btn btn--ghost">
|
||||
or {SITE.email}
|
||||
</a>
|
||||
|
|
@ -383,14 +222,14 @@ export default function Page() {
|
|||
</section>
|
||||
</main>
|
||||
|
||||
{/* =========================================================
|
||||
FOOTER — the colophon
|
||||
========================================================= */}
|
||||
{/* ===== FOOTER ===== */}
|
||||
<footer data-invert className="colophon frame" aria-label="Footer">
|
||||
<div className="wrap">
|
||||
<div className="colophon__top">
|
||||
<div className="colophon__brand">
|
||||
<span className="logo__bug" aria-hidden="true">FS</span>
|
||||
<span className="brand__mark" aria-hidden="true">
|
||||
<span className="brand__dot" />
|
||||
</span>
|
||||
<p className="colophon__line">
|
||||
{SITE.name}. {SITE.tagline}
|
||||
</p>
|
||||
|
|
@ -399,7 +238,7 @@ export default function Page() {
|
|||
<ul>
|
||||
<li><a href="#services">Services</a></li>
|
||||
<li><a href="#work">Work</a></li>
|
||||
<li><a href="#about">About</a></li>
|
||||
<li><a href="#process">About</a></li>
|
||||
<li><a href="#faq">FAQ</a></li>
|
||||
<li><a href={`mailto:${SITE.email}`}>Contact</a></li>
|
||||
</ul>
|
||||
|
|
|
|||
69
package-lock.json
generated
69
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"gsap": "^3.12.5",
|
||||
"lenis": "^1.1.14",
|
||||
"motion": "^12.40.0",
|
||||
"next": "^15.5.19",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "19.0.0",
|
||||
|
|
@ -718,6 +719,33 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.40.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
|
||||
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.40.0",
|
||||
"motion-utils": "^12.39.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
||||
|
|
@ -755,6 +783,47 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.40.0",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz",
|
||||
"integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.40.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.40.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
|
||||
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.39.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.39.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"dependencies": {
|
||||
"gsap": "^3.12.5",
|
||||
"lenis": "^1.1.14",
|
||||
"motion": "^12.40.0",
|
||||
"next": "^15.5.19",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "19.0.0",
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2 MiB After Width: | Height: | Size: 2 MiB |
BIN
public/assets/hero.mp4
Normal file
BIN
public/assets/hero.mp4
Normal file
Binary file not shown.
Loading…
Reference in a new issue