feat: 'The Feedback Terminal' — bold editorial/financial-broadsheet homepage redesign
This commit is contained in:
parent
ae0be1c48e
commit
30bed043ec
23 changed files with 1839 additions and 2678 deletions
103
app/components/BeforeAfter.tsx
Normal file
103
app/components/BeforeAfter.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CaseStudies — large film-cell cards. Each is a real <article> 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 (
|
|
||||||
<section id="work" className="cases" aria-label="Case studies">
|
|
||||||
<div className="wrap">
|
|
||||||
<Reveal>
|
|
||||||
<p className="kicker">Case studies</p>
|
|
||||||
<h2 className="section__title">Proof, not promises.</h2>
|
|
||||||
<p className="section__lead">
|
|
||||||
A few of the businesses we've grown. Same approach every time:
|
|
||||||
tie the work to the revenue, then prove it.
|
|
||||||
</p>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<div className="cases__list">
|
|
||||||
{cases.map((c, i) => (
|
|
||||||
<Reveal key={c.tag} y={60}>
|
|
||||||
<article className="case hoverable" data-cursor="view work">
|
|
||||||
<div className="case__media">
|
|
||||||
<span className="case__tag">{c.tag}</span>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={c.img}
|
|
||||||
alt={c.alt}
|
|
||||||
className="case__img"
|
|
||||||
width={900}
|
|
||||||
height={760}
|
|
||||||
loading={i === 0 ? "eager" : "lazy"}
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="case__body">
|
|
||||||
<p className="case__problem">{c.problem}</p>
|
|
||||||
<h3 className="case__result">
|
|
||||||
<em>{c.result}</em>
|
|
||||||
</h3>
|
|
||||||
<p className="case__how">{c.how}</p>
|
|
||||||
<p className="case__metric">
|
|
||||||
<span className="case__metric-num">{c.metricNum}</span>
|
|
||||||
<span className="case__metric-label">{c.metricLabel}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="cases__cta">
|
|
||||||
<a className="btn btn--ghost hoverable" href="#contact">
|
|
||||||
<span>View all work →</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +1,69 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import { useEffect, useRef, useState } from "react";
|
||||||
* 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);
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: number;
|
to: number;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
decimals?: number;
|
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({
|
export default function CountUp({
|
||||||
value,
|
to,
|
||||||
prefix = "",
|
prefix = "",
|
||||||
suffix = "",
|
suffix = "",
|
||||||
decimals = 0,
|
decimals = 0,
|
||||||
className = "",
|
duration = 1600,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [val, setVal] = useState(to);
|
||||||
const format = (n: number) =>
|
const done = useRef(false);
|
||||||
`${prefix}${n.toLocaleString("en-US", {
|
|
||||||
minimumFractionDigits: decimals,
|
|
||||||
maximumFractionDigits: decimals,
|
|
||||||
})}${suffix}`;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
if (!el) return;
|
if (reduce) {
|
||||||
|
setVal(to);
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
||||||
el.textContent = format(value);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setVal(0);
|
||||||
|
const node = ref.current;
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
const obj = { n: 0 };
|
const io = new IntersectionObserver(
|
||||||
el.textContent = format(0);
|
([e]) => {
|
||||||
|
if (!e.isIntersecting || done.current) return;
|
||||||
const ctx = gsap.context(() => {
|
done.current = true;
|
||||||
gsap.to(obj, {
|
const start = performance.now();
|
||||||
n: value,
|
const tick = (now: number) => {
|
||||||
duration: 2,
|
const p = Math.min((now - start) / duration, 1);
|
||||||
ease: "power2.out",
|
const eased = 1 - Math.pow(1 - p, 3);
|
||||||
scrollTrigger: { trigger: el, start: "top 85%", once: true },
|
setVal(to * eased);
|
||||||
onUpdate: () => {
|
if (p < 1) requestAnimationFrame(tick);
|
||||||
el.textContent = format(obj.n);
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
io.disconnect();
|
||||||
},
|
},
|
||||||
});
|
{ threshold: 0.4 }
|
||||||
}, el);
|
);
|
||||||
|
io.observe(node);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [to, duration]);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<span ref={ref} className={className}>
|
<span ref={ref}>
|
||||||
{format(value)}
|
{prefix}
|
||||||
|
{val.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
})}
|
||||||
|
{suffix}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,64 @@
|
||||||
"use client";
|
"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 { 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() {
|
export default function Cursor() {
|
||||||
const dot = useRef<HTMLDivElement>(null);
|
|
||||||
const ring = useRef<HTMLDivElement>(null);
|
const ring = useRef<HTMLDivElement>(null);
|
||||||
const label = useRef<HTMLSpanElement>(null);
|
const label = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.matchMedia("(pointer: coarse)").matches) return;
|
const fine = window.matchMedia("(pointer: fine)").matches;
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
if (!dot.current || !ring.current) return;
|
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" });
|
let x = window.innerWidth / 2;
|
||||||
const yD = gsap.quickTo(dot.current, "y", { duration: 0.12, ease: "power3" });
|
let y = window.innerHeight / 2;
|
||||||
const xR = gsap.quickTo(ring.current, "x", { duration: 0.55, ease: "power3" });
|
let cx = x;
|
||||||
const yR = gsap.quickTo(ring.current, "y", { duration: 0.55, ease: "power3" });
|
let cy = y;
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
const move = (e: MouseEvent) => {
|
const move = (e: PointerEvent) => {
|
||||||
xD(e.clientX);
|
x = e.clientX;
|
||||||
yD(e.clientY);
|
y = e.clientY;
|
||||||
xR(e.clientX);
|
const t = e.target as HTMLElement;
|
||||||
yR(e.clientY);
|
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 loop = () => {
|
||||||
const t = (e.target as HTMLElement).closest<HTMLElement>(
|
cx += (x - cx) * 0.2;
|
||||||
"a,button,.hoverable,[data-cursor]"
|
cy += (y - cy) * 0.2;
|
||||||
);
|
el.style.transform = `translate3d(${cx}px, ${cy}px, 0) translate(-50%, -50%)`;
|
||||||
if (!t) return;
|
raf = requestAnimationFrame(loop);
|
||||||
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<HTMLElement>(
|
|
||||||
"a,button,.hoverable,[data-cursor]"
|
|
||||||
);
|
|
||||||
if (!t) return;
|
|
||||||
ring.current?.classList.remove("cursor-ring--big", "cursor-ring--label");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("mousemove", move, { passive: true });
|
document.body.classList.add("has-cursor");
|
||||||
document.addEventListener("mouseover", over);
|
window.addEventListener("pointermove", move);
|
||||||
document.addEventListener("mouseout", out);
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.classList.remove("has-custom-cursor");
|
window.removeEventListener("pointermove", move);
|
||||||
window.removeEventListener("mousemove", move);
|
cancelAnimationFrame(raf);
|
||||||
document.removeEventListener("mouseover", over);
|
document.body.classList.remove("has-cursor");
|
||||||
document.removeEventListener("mouseout", out);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div ref={ring} className="cursor" aria-hidden="true" data-active="0">
|
||||||
<div ref={ring} className="cursor-ring" aria-hidden="true">
|
<span className="cursor__cross" />
|
||||||
<span ref={label} className="cursor-ring__label" />
|
<span ref={label} className="cursor__label" />
|
||||||
</div>
|
</div>
|
||||||
<div ref={dot} className="cursor-dot" aria-hidden="true" />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,45 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Faq — accessible accordion built on real <button> controls with
|
|
||||||
* aria-expanded / aria-controls and a CSS grid-rows reveal (no JS height math,
|
|
||||||
* animates cleanly, collapsible content stays in the DOM for SEO/AEO). The
|
|
||||||
* answer copy matches the FAQPage JSON-LD in layout.tsx verbatim.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Reveal from "./Reveal";
|
|
||||||
import { faqs } from "../content";
|
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() {
|
export default function Faq() {
|
||||||
const [open, setOpen] = useState<number | null>(0);
|
const [open, setOpen] = useState<number | null>(0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="faq" className="faq" aria-label="Frequently asked questions">
|
<div className="faq">
|
||||||
<div className="wrap faq__inner">
|
|
||||||
<div className="faq__head">
|
|
||||||
<p className="kicker">FAQ</p>
|
|
||||||
<h2 className="section__title">
|
|
||||||
Questions about working with a digital marketing agency
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="faq__list">
|
|
||||||
{faqs.map((f, i) => {
|
{faqs.map((f, i) => {
|
||||||
const isOpen = open === i;
|
const isOpen = open === i;
|
||||||
return (
|
return (
|
||||||
<div className="faq__item" key={f.q} data-open={isOpen}>
|
<div className="faq__item" key={i}>
|
||||||
<h3>
|
<h3 className="faq__h">
|
||||||
<button
|
<button
|
||||||
type="button"
|
className="faq__btn"
|
||||||
className="faq__q hoverable"
|
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-controls={`faq-a-${i}`}
|
aria-controls={`faq-panel-${i}`}
|
||||||
id={`faq-q-${i}`}
|
id={`faq-btn-${i}`}
|
||||||
onClick={() => setOpen(isOpen ? null : i)}
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
>
|
>
|
||||||
{f.q}
|
<span className="faq__num">Q{i + 1}</span>
|
||||||
<span className="faq__icon" aria-hidden="true" />
|
<span className="faq__q">{f.q}</span>
|
||||||
|
<span className="faq__sign" aria-hidden="true" data-open={isOpen}>
|
||||||
|
<i /><i />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
<div
|
<div
|
||||||
className="faq__a"
|
id={`faq-panel-${i}`}
|
||||||
id={`faq-a-${i}`}
|
|
||||||
role="region"
|
role="region"
|
||||||
aria-labelledby={`faq-q-${i}`}
|
aria-labelledby={`faq-btn-${i}`}
|
||||||
|
className="faq__panel"
|
||||||
|
data-open={isOpen}
|
||||||
>
|
>
|
||||||
<div className="faq__a-inner">
|
<div className="faq__panel-inner">
|
||||||
<p>{f.a}</p>
|
<p>{f.a}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -56,7 +47,5 @@ export default function Faq() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<HTMLElement>(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 (
|
|
||||||
<section className="hero" ref={root}>
|
|
||||||
{/* iridescent wave accent — decorative texture layer */}
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src="/assets/hero-iridescent-1.png"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="hero__accent"
|
|
||||||
width={1344}
|
|
||||||
height={768}
|
|
||||||
fetchPriority="high"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="hero__inner wrap">
|
|
||||||
<p className="hero__eyebrow">
|
|
||||||
<span className="dot" aria-hidden="true" />
|
|
||||||
Digital marketing agency
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 className="hero__display">
|
|
||||||
<KineticText as="span" className="hero__line" text="Marketing that" immediate delay={0.25} />
|
|
||||||
<KineticText as="span" className="hero__line" text="grows your" immediate delay={0.38} />
|
|
||||||
<KineticText
|
|
||||||
as="span"
|
|
||||||
className="hero__line hero__line--grad"
|
|
||||||
text="revenue."
|
|
||||||
immediate
|
|
||||||
delay={0.5}
|
|
||||||
highlight={[0, 0]}
|
|
||||||
/>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="hero__sub">
|
|
||||||
We're a results-driven digital marketing agency. We run paid, SEO,
|
|
||||||
and content programs built around <strong>your revenue targets</strong>,
|
|
||||||
then show you what they returned. Every month.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="hero__actions">
|
|
||||||
<Magnetic strength={0.5}>
|
|
||||||
<a className="btn btn--primary hoverable" href="#contact" data-cursor="let's talk">
|
|
||||||
<span>Get your growth audit</span>
|
|
||||||
</a>
|
|
||||||
</Magnetic>
|
|
||||||
<a className="hero__secondary hoverable" href="#results">
|
|
||||||
See the results <span aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="hero__trust">
|
|
||||||
<strong>$40M+</strong> in client revenue generated
|
|
||||||
<span className="hero__trust-sep" aria-hidden="true" />
|
|
||||||
<strong>50+</strong> brands grown
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a className="hero__scroll hoverable" href="#position" aria-label="Scroll to the next section">
|
|
||||||
<span>scroll</span>
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
||||||
<path d="M12 4v16m0 0l-6-6m6 6l6-6" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<HTMLCanvasElement>(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 (
|
|
||||||
<div className="iridescence" aria-hidden="true">
|
|
||||||
<canvas ref={ref} className="iridescence__canvas" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
64
app/components/KineticHeadline.tsx
Normal file
64
app/components/KineticHeadline.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<HTMLElement>(null);
|
|
||||||
const words = text.split(" ");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const inner = gsap.utils.toArray<HTMLElement>(".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 (
|
|
||||||
<span className={`ktext__word${hot}`} key={i}>
|
|
||||||
<span className="ktext__in">{w}</span>
|
|
||||||
{i < words.length - 1 ? " " : ""}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return createElement(Tag, { ref, className: `ktext ${className}` }, children);
|
|
||||||
}
|
|
||||||
|
|
@ -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<HTMLSpanElement>(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 (
|
|
||||||
<span ref={ref} className={`magnetic ${className}`}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<div className="marquee__row" aria-hidden="true">
|
|
||||||
{items.map((it) => (
|
|
||||||
<span className="marquee__item" key={it}>
|
|
||||||
{it}
|
|
||||||
<span className="marquee__sep" />
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Marquee() {
|
|
||||||
return (
|
|
||||||
<section className="proof" aria-label="Industries we work with">
|
|
||||||
<p className="proof__label">
|
|
||||||
Trusted by teams that care about the sales number
|
|
||||||
</p>
|
|
||||||
<div className="marquee">
|
|
||||||
<div className="marquee__track">
|
|
||||||
<Row />
|
|
||||||
<Row />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<section id="results" className="metrics" aria-label="Results">
|
|
||||||
<div className="wrap">
|
|
||||||
<Reveal>
|
|
||||||
<p className="kicker">The numbers</p>
|
|
||||||
<h2 className="section__title">Results we can show you.</h2>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<div className="metrics__inner">
|
|
||||||
<Reveal className="metrics__grid" stagger={0.1}>
|
|
||||||
{metrics.map((m) => (
|
|
||||||
<div
|
|
||||||
className={`metric${"accent" in m && m.accent ? " metric--accent" : ""}`}
|
|
||||||
key={m.label}
|
|
||||||
>
|
|
||||||
<p className="metric__num">
|
|
||||||
<CountUp
|
|
||||||
value={m.value}
|
|
||||||
prefix={"prefix" in m ? m.prefix : ""}
|
|
||||||
suffix={m.suffix}
|
|
||||||
decimals={"decimals" in m ? m.decimals : 0}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<p className="metric__label">{m.label}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<SVGSVGElement>(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<SVGElement>(".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 (
|
|
||||||
<svg
|
|
||||||
ref={ref}
|
|
||||||
className={`pillmark ${className}`}
|
|
||||||
viewBox="0 0 1192 1287"
|
|
||||||
role={title ? "img" : "presentation"}
|
|
||||||
aria-hidden={title ? undefined : "true"}
|
|
||||||
aria-label={title}
|
|
||||||
>
|
|
||||||
{title ? <title>{title}</title> : null}
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="pm-grad" x1="0" y1="0" x2="1192" y2="0" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stopColor="#8b5cf6" />
|
|
||||||
<stop offset="1" stopColor="#3b82f6" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
{/* top: full-width violet→blue gradient capsule */}
|
|
||||||
<rect className="pillmark__bar" x="0" y="0" width="1192" height="348.64" rx="174.32" fill="url(#pm-grad)" />
|
|
||||||
{/* middle row: ink wide capsule + blue square capsule */}
|
|
||||||
<rect className="pillmark__bar" x="0.02" y="480.64" width="725.02" height="348.64" rx="174.32" fill="#111827" />
|
|
||||||
<rect className="pillmark__bar" x="843" y="480.28" width="349.04" height="348.98" rx="174.32" fill="#3b82f6" />
|
|
||||||
{/* bottom: full-width emerald capsule */}
|
|
||||||
<rect className="pillmark__bar" x="0.04" y="938.21" width="1191.97" height="348.64" rx="174.32" fill="#10b981" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<HTMLElement>(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<HTMLElement>(".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 (
|
|
||||||
<section id="process" className="process" ref={root} aria-label="How it works">
|
|
||||||
<div className="wrap process__inner">
|
|
||||||
<div className="process__sticky">
|
|
||||||
<p className="kicker">How it works</p>
|
|
||||||
<h2 className="section__title">
|
|
||||||
The <span className="serif-em">Feedback Loop.</span>
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
className="process__count"
|
|
||||||
aria-hidden="true"
|
|
||||||
key={active}
|
|
||||||
>
|
|
||||||
0{active + 1}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="process__steps">
|
|
||||||
{processSteps.map((p, i) => (
|
|
||||||
<li
|
|
||||||
className={`pstep${i <= active ? " is-active" : ""}`}
|
|
||||||
key={p.n}
|
|
||||||
aria-current={i === active ? "step" : undefined}
|
|
||||||
>
|
|
||||||
<div className="pstep__top">
|
|
||||||
<span className="pstep__n">{p.n}</span>
|
|
||||||
<h3 className="pstep__name">{p.name}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="pstep__desc">{p.desc}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +1,49 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { gsap } from "gsap";
|
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reveal — generic on-scroll entrance with personality (slide + soft clip),
|
* Lightweight scroll reveal via IntersectionObserver (no GSAP dependency
|
||||||
* not a plain fade-up. With `stagger`, animates direct children in sequence.
|
* so it works even before the smooth-scroll module mounts). Adds .is-in.
|
||||||
* Respects reduced-motion (content shown immediately).
|
* CSS already shows content under reduced motion.
|
||||||
*/
|
*/
|
||||||
export default function Reveal({
|
export default function Reveal({
|
||||||
children,
|
children,
|
||||||
|
as: Tag = "div",
|
||||||
className = "",
|
className = "",
|
||||||
stagger = 0,
|
delay = 0,
|
||||||
y = 44,
|
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: React.ReactNode;
|
||||||
|
as?: keyof React.JSX.IntrinsicElements;
|
||||||
className?: string;
|
className?: string;
|
||||||
stagger?: number;
|
delay?: number;
|
||||||
y?: number;
|
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
const io = new IntersectionObserver(
|
||||||
|
([e]) => {
|
||||||
const targets = stagger > 0 ? Array.from(el.children) : [el];
|
if (e.isIntersecting) {
|
||||||
|
el.classList.add("is-in");
|
||||||
const ctx = gsap.context(() => {
|
io.disconnect();
|
||||||
gsap.from(targets, {
|
}
|
||||||
y,
|
},
|
||||||
opacity: 0,
|
{ threshold: 0.15, rootMargin: "0px 0px -8% 0px" }
|
||||||
filter: "blur(8px)",
|
);
|
||||||
duration: 1.05,
|
io.observe(el);
|
||||||
ease: "power3.out",
|
return () => io.disconnect();
|
||||||
stagger,
|
}, []);
|
||||||
scrollTrigger: { trigger: el, start: "top 85%" },
|
|
||||||
});
|
|
||||||
}, el);
|
|
||||||
|
|
||||||
return () => ctx.revert();
|
|
||||||
}, [stagger, y]);
|
|
||||||
|
|
||||||
|
const Comp = Tag as React.ElementType;
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={className}>
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
className={`reveal ${className}`}
|
||||||
|
style={{ transitionDelay: `${delay}ms` }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Comp>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
206
app/components/RevenueField.tsx
Normal file
206
app/components/RevenueField.tsx
Normal file
|
|
@ -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<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" />;
|
||||||
|
}
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Services — deliberately broken editorial grid. Six services on a 6-column
|
|
||||||
* grid; the first two cards span 3 (breaking the safe 2-col-per-card rhythm),
|
|
||||||
* the rest span 2. Serif index numerals, spectrum top-line on hover.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Reveal from "./Reveal";
|
|
||||||
import { services } from "../content";
|
|
||||||
|
|
||||||
export default function Services() {
|
|
||||||
return (
|
|
||||||
<section id="services" className="services" aria-label="Services">
|
|
||||||
<div className="wrap">
|
|
||||||
<Reveal>
|
|
||||||
<p className="kicker">What we do</p>
|
|
||||||
<h2 className="section__title">How we grow your business.</h2>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<Reveal className="services__grid" stagger={0.08}>
|
|
||||||
{services.map((s, i) => (
|
|
||||||
<article
|
|
||||||
className={`service hoverable${i < 2 ? " service--wide" : ""}`}
|
|
||||||
key={s.id}
|
|
||||||
data-cursor="more"
|
|
||||||
>
|
|
||||||
<span className="service__index">0{i + 1}</span>
|
|
||||||
<h3 className="service__name">{s.name}</h3>
|
|
||||||
<p className="service__desc">{s.desc}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +1,104 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import { useEffect, useState } from "react";
|
||||||
* SiteHeader — fixed top nav. Compacts (adds a glass backdrop) once you scroll
|
import { SITE } from "../content";
|
||||||
* past the hero, and includes a mobile menu toggle. The wordmark is real text
|
|
||||||
* ("feedback studios") with the brand mark inline.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
const NAV = [
|
||||||
import PillMark from "./PillMark";
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
{ href: "#services", label: "Services" },
|
{ href: "#services", label: "Services" },
|
||||||
{ href: "#work", label: "Work" },
|
{ href: "#work", label: "Work" },
|
||||||
{ href: "#process", label: "About" },
|
{ href: "#about", label: "About" },
|
||||||
{ href: "#faq", label: "FAQ" },
|
{ href: "#faq", label: "FAQ" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Masthead-style sticky header with a mono "ticker bug" logo. */
|
||||||
export default function SiteHeader() {
|
export default function SiteHeader() {
|
||||||
const [scrolled, setScrolled] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const headerRef = useRef<HTMLElement>(null);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 80);
|
const onScroll = () => setScrolled(window.scrollY > 24);
|
||||||
onScroll();
|
onScroll();
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", onScroll);
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close the mobile menu on Escape.
|
// lock body + escape to close mobile nav
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
document.body.style.overflow = open ? "hidden" : "";
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||||
if (e.key === "Escape") setOpen(false);
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = "";
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header className={`masthead ${scrolled ? "is-scrolled" : ""}`}>
|
||||||
ref={headerRef}
|
<div className="masthead__bar frame">
|
||||||
className={`site-header${scrolled ? " is-scrolled" : ""}${open ? " is-open" : ""}`}
|
<a href="#main" className="logo" aria-label="Feedback Studios — home">
|
||||||
>
|
<span className="logo__bug" aria-hidden="true">
|
||||||
<div className="site-header__inner">
|
FS
|
||||||
<a className="brand hoverable" href="#top" aria-label="Feedback Studios, home">
|
</span>
|
||||||
<PillMark className="brand__mark" title="Feedback Studios" />
|
<span className="logo__name">Feedback Studios</span>
|
||||||
<span className="brand__word">
|
<span className="logo__tag" aria-hidden="true">
|
||||||
feedback<span className="brand__word-2">studios</span>
|
EST. ’26 · REV
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav className="site-nav" aria-label="Primary">
|
<nav className="masthead__nav" aria-label="Primary">
|
||||||
<ul>
|
<ul>
|
||||||
{links.map((l) => (
|
{NAV.map((n) => (
|
||||||
<li key={l.href}>
|
<li key={n.href}>
|
||||||
<a className="hoverable" href={l.href} onClick={() => setOpen(false)}>
|
<a href={n.href}>{n.label}</a>
|
||||||
{l.label}
|
</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>
|
||||||
|
{n.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<a className="btn btn--sm btn--primary hoverable" href="#contact" onClick={() => setOpen(false)}>
|
|
||||||
<span>Get a growth audit</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
<a
|
||||||
<button
|
className="btn btn--accent"
|
||||||
type="button"
|
href={SITE.booking}
|
||||||
className="site-header__toggle hoverable"
|
onClick={() => setOpen(false)}
|
||||||
aria-expanded={open}
|
|
||||||
aria-label={open ? "Close menu" : "Open menu"}
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
>
|
>
|
||||||
<span />
|
Get a growth audit
|
||||||
<span />
|
</a>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,46 +5,49 @@ import Lenis from "lenis";
|
||||||
import { gsap } from "gsap";
|
import { gsap } from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
/**
|
||||||
|
* Lenis smooth scroll wired into GSAP's ScrollTrigger so scroll-driven
|
||||||
|
* animation stays in sync. Disabled entirely under reduced-motion.
|
||||||
|
*/
|
||||||
export default function SmoothScroll() {
|
export default function SmoothScroll() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Respect reduced-motion: skip momentum scrolling entirely.
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
if (reduce) return;
|
||||||
ScrollTrigger.refresh();
|
|
||||||
return;
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
}
|
|
||||||
|
|
||||||
const lenis = new Lenis({
|
const lenis = new Lenis({
|
||||||
duration: 1.15,
|
duration: 1.1,
|
||||||
smoothWheel: true,
|
|
||||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||||
|
smoothWheel: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
lenis.on("scroll", ScrollTrigger.update);
|
lenis.on("scroll", ScrollTrigger.update);
|
||||||
|
|
||||||
const ticker = (time: number) => lenis.raf(time * 1000);
|
const raf = (time: number) => lenis.raf(time * 1000);
|
||||||
gsap.ticker.add(ticker);
|
gsap.ticker.add(raf);
|
||||||
gsap.ticker.lagSmoothing(0);
|
gsap.ticker.lagSmoothing(0);
|
||||||
|
|
||||||
// Anchor links should route through Lenis for the smooth glide.
|
// anchor links route through Lenis
|
||||||
const onClick = (e: MouseEvent) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
const a = (e.target as HTMLElement).closest('a[href^="#"]');
|
const a = (e.target as HTMLElement)?.closest('a[href^="#"]') as
|
||||||
|
| HTMLAnchorElement
|
||||||
|
| null;
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
const id = a.getAttribute("href");
|
const id = a.getAttribute("href");
|
||||||
if (!id || id === "#") return;
|
if (!id || id === "#") return;
|
||||||
const target = document.querySelector(id);
|
const el = document.querySelector(id);
|
||||||
if (target) {
|
if (!el) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
lenis.scrollTo(target as HTMLElement, { offset: -20 });
|
lenis.scrollTo(el as HTMLElement, { offset: -80 });
|
||||||
}
|
|
||||||
};
|
};
|
||||||
document.addEventListener("click", onClick);
|
document.addEventListener("click", onClick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("click", onClick);
|
document.removeEventListener("click", onClick);
|
||||||
gsap.ticker.remove(ticker);
|
gsap.ticker.remove(raf);
|
||||||
lenis.destroy();
|
lenis.destroy();
|
||||||
|
ScrollTrigger.getAll().forEach((t) => t.kill());
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
67
app/components/Ticker.tsx
Normal file
67
app/components/Ticker.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2170
app/globals.css
2170
app/globals.css
File diff suppressed because it is too large
Load diff
|
|
@ -92,7 +92,8 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
{/* Satoshi (workhorse) + Instrument Serif (editorial accent) */}
|
{/* Satoshi (workhorse sans) + Newsreader (editorial display, in
|
||||||
|
globals via @import) + Spline Sans Mono (data/labels). */}
|
||||||
<link rel="preconnect" href="https://api.fontshare.com" crossOrigin="" />
|
<link rel="preconnect" href="https://api.fontshare.com" crossOrigin="" />
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
|
|
@ -100,12 +101,8 @@ export default function RootLayout({
|
||||||
/>
|
/>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&display=swap"
|
|
||||||
/>
|
|
||||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||||
<meta name="theme-color" content="#08080c" />
|
<meta name="theme-color" content="#f4f1ea" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<StructuredData />
|
<StructuredData />
|
||||||
|
|
|
||||||
533
app/page.tsx
533
app/page.tsx
|
|
@ -1,173 +1,424 @@
|
||||||
import Iridescence from "./components/Iridescence";
|
import Link from "next/link";
|
||||||
import SiteHeader from "./components/SiteHeader";
|
import Image from "next/image";
|
||||||
import Hero from "./components/Hero";
|
import {
|
||||||
import Marquee from "./components/Marquee";
|
SITE,
|
||||||
import Reveal from "./components/Reveal";
|
services,
|
||||||
import KineticText from "./components/KineticText";
|
metrics,
|
||||||
import Services from "./components/Services";
|
cases,
|
||||||
import Metrics from "./components/Metrics";
|
processSteps,
|
||||||
import CaseStudies from "./components/CaseStudies";
|
testimonials,
|
||||||
import Process from "./components/Process";
|
} from "./content";
|
||||||
import Faq from "./components/Faq";
|
|
||||||
import PillMark from "./components/PillMark";
|
|
||||||
import Magnetic from "./components/Magnetic";
|
|
||||||
import { testimonials } from "./content";
|
|
||||||
|
|
||||||
export default function Home() {
|
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 Faq from "./components/Faq";
|
||||||
|
|
||||||
|
const PROOF = [
|
||||||
|
"E-commerce",
|
||||||
|
"B2B SaaS",
|
||||||
|
"Clinics",
|
||||||
|
"Professional services",
|
||||||
|
"DTC brands",
|
||||||
|
"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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Iridescence />
|
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<span id="top" />
|
{/* =========================================================
|
||||||
<Hero />
|
HERO — the masthead front page
|
||||||
|
========================================================= */}
|
||||||
|
<section className="hero frame" aria-labelledby="hero-h1">
|
||||||
|
<div className="hero__field">
|
||||||
|
<RevenueField />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* SOCIAL PROOF BAR */}
|
{/* dateline / edition strip */}
|
||||||
<Marquee />
|
<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" />
|
||||||
|
|
||||||
{/* POSITIONING / PROBLEM — big editorial statement */}
|
<div className="hero__grid">
|
||||||
<section id="position" className="positioning" aria-label="Our positioning">
|
<div className="hero__lead">
|
||||||
<div className="wrap positioning__grid">
|
<p className="kicker">
|
||||||
<Reveal>
|
<span className="kicker__dot" />
|
||||||
<p className="kicker">Why we exist</p>
|
Marketing, priced in revenue
|
||||||
<p className="positioning__statement">
|
|
||||||
Most marketing budgets buy{" "}
|
|
||||||
<span className="muted">activity</span>, not{" "}
|
|
||||||
<span className="serif-em">outcomes.</span>
|
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
<KineticHeadline className="display hero__h1">
|
||||||
<Reveal className="positioning__aside" y={40}>
|
<span id="hero-h1">
|
||||||
<span className="serif-em">We started to fix that.</span>
|
Marketing that grows your <span className="cash">revenue.</span>
|
||||||
Dashboards fill up with impressions and “engagement”
|
|
||||||
while the sales number sits still. Every campaign we run is built
|
|
||||||
to move revenue, and we report on it the way your CFO would.
|
|
||||||
</Reveal>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* SERVICES — broken grid */}
|
|
||||||
<Services />
|
|
||||||
|
|
||||||
{/* RESULTS — animated count-up */}
|
|
||||||
<Metrics />
|
|
||||||
|
|
||||||
{/* CASE STUDIES — large film-cell cards */}
|
|
||||||
<CaseStudies />
|
|
||||||
|
|
||||||
{/* PROCESS — pinned scrollytelling */}
|
|
||||||
<Process />
|
|
||||||
|
|
||||||
{/* TESTIMONIALS + PARTNERS */}
|
|
||||||
<section id="voices" className="tmonials" aria-label="What clients say">
|
|
||||||
<div className="wrap">
|
|
||||||
<Reveal>
|
|
||||||
<p className="kicker">In their words</p>
|
|
||||||
<h2 className="section__title">
|
|
||||||
The number is the <span className="serif-em">whole point.</span>
|
|
||||||
</h2>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<Reveal className="tmonials__grid" stagger={0.12}>
|
|
||||||
{testimonials.map((t) => (
|
|
||||||
<figure className="tmonial" key={t.by}>
|
|
||||||
<span className="tmonial__mark" aria-hidden="true">
|
|
||||||
“
|
|
||||||
</span>
|
</span>
|
||||||
<blockquote>
|
</KineticHeadline>
|
||||||
<p className="tmonial__quote">{t.quote}</p>
|
</div>
|
||||||
</blockquote>
|
|
||||||
<figcaption className="tmonial__by">{t.by}</figcaption>
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
<Reveal className="partners">
|
<div className="hero__side">
|
||||||
<span className="partners__label">Partners</span>
|
<p className="hero__sub">
|
||||||
<span className="partner">Google Partner</span>
|
We're a results-driven digital marketing agency. We run
|
||||||
<span className="partner">Meta Business Partner</span>
|
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
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<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">
|
||||||
|
{p}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</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>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* =========================================================
|
||||||
<Faq />
|
SERVICES — the index / ledger
|
||||||
|
========================================================= */}
|
||||||
{/* FINAL CTA */}
|
<section id="services" className="services frame" aria-labelledby="svc-h">
|
||||||
<section id="contact" className="cta" aria-label="Get in touch">
|
<div className="wrap">
|
||||||
<span className="cta__glow" aria-hidden="true" />
|
<header className="sec-head">
|
||||||
<Reveal className="wrap cta__wrap">
|
<p className="kicker">
|
||||||
<PillMark className="cta__mark" animate breathe />
|
<span className="kicker__dot" />
|
||||||
<h2 className="cta__title">
|
Services / 06
|
||||||
<KineticText as="span" text="Ready to" />{" "}
|
</p>
|
||||||
<KineticText
|
<h2 id="svc-h" className="display sec-head__title">
|
||||||
as="span"
|
How we grow your business
|
||||||
className="cta__title-grad"
|
|
||||||
text="grow?"
|
|
||||||
highlight={[0, 0]}
|
|
||||||
/>
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="cta__lead">
|
</header>
|
||||||
|
|
||||||
|
<ol className="ledger">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* =========================================================
|
||||||
|
CASE STUDIES — proof spreads w/ before/after sliders
|
||||||
|
========================================================= */}
|
||||||
|
<section id="work" className="work frame" aria-labelledby="work-h">
|
||||||
|
<div className="wrap">
|
||||||
|
<header className="sec-head">
|
||||||
|
<p className="kicker">
|
||||||
|
<span className="kicker__dot" />
|
||||||
|
Selected work — sample
|
||||||
|
</p>
|
||||||
|
<h2 id="work-h" className="display sec-head__title">
|
||||||
|
Proof, not promises
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="work__list">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="work__cta">
|
||||||
|
<a href={SITE.booking} className="btn btn--ghost">
|
||||||
|
View all work <span className="arrow" aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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
|
||||||
|
========================================================= */}
|
||||||
|
<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>
|
||||||
|
<figcaption className="quote__by">
|
||||||
|
<span className="quote__rule" aria-hidden="true" /> {t.by}
|
||||||
|
</figcaption>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="wrap partners">
|
||||||
|
<p className="kicker">Partners</p>
|
||||||
|
<ul className="partners__list">
|
||||||
|
<li>Google Partner</li>
|
||||||
|
<li>Meta Business Partner</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* =========================================================
|
||||||
|
FAQ
|
||||||
|
========================================================= */}
|
||||||
|
<section id="faq" data-invert className="faq-sec frame" aria-labelledby="faq-h">
|
||||||
|
<div className="wrap faq-sec__wrap">
|
||||||
|
<header className="sec-head">
|
||||||
|
<p className="kicker">
|
||||||
|
<span className="kicker__dot" />
|
||||||
|
FAQ
|
||||||
|
</p>
|
||||||
|
<h2 id="faq-h" className="display sec-head__title">
|
||||||
|
Questions about working with a digital marketing agency
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
<Faq />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* =========================================================
|
||||||
|
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>
|
||||||
|
<p className="final__sub">
|
||||||
No long contracts. No vanity reports. Marketing you can measure in
|
No long contracts. No vanity reports. Marketing you can measure in
|
||||||
sales.
|
sales.
|
||||||
</p>
|
</p>
|
||||||
<Magnetic strength={0.4}>
|
<div className="final__cta">
|
||||||
<a
|
<Link href={SITE.booking} className="btn btn--accent" data-cursor="Book a call">
|
||||||
className="btn btn--primary btn--lg hoverable"
|
Book a call
|
||||||
href="https://cal.feedback-studios.com"
|
</Link>
|
||||||
data-cursor="book a call"
|
<a href={`mailto:${SITE.email}`} className="btn btn--ghost">
|
||||||
>
|
or {SITE.email}
|
||||||
<span>Book a call</span>
|
|
||||||
</a>
|
</a>
|
||||||
</Magnetic>
|
</div>
|
||||||
<p className="cta__mail">
|
</div>
|
||||||
or email{" "}
|
|
||||||
<a className="hoverable" href="mailto:hello@feedbackstudios.com">
|
|
||||||
hello@feedbackstudios.com
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</Reveal>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="site-footer">
|
{/* =========================================================
|
||||||
<div className="wrap site-footer__inner">
|
FOOTER — the colophon
|
||||||
<div className="site-footer__brand">
|
========================================================= */}
|
||||||
<span className="brand__word brand__word--lg">
|
<footer data-invert className="colophon frame" aria-label="Footer">
|
||||||
feedback<span className="brand__word-2">studios</span>
|
<div className="wrap">
|
||||||
</span>
|
<div className="colophon__top">
|
||||||
<p className="site-footer__tag">
|
<div className="colophon__brand">
|
||||||
A results-driven digital marketing agency. We build paid, SEO and
|
<span className="logo__bug" aria-hidden="true">FS</span>
|
||||||
content programs around your revenue.
|
<p className="colophon__line">
|
||||||
|
{SITE.name}. {SITE.tagline}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<nav className="colophon__nav" aria-label="Footer">
|
||||||
<div className="site-footer__col">
|
<ul>
|
||||||
<h2>Sitemap</h2>
|
<li><a href="#services">Services</a></li>
|
||||||
<nav aria-label="Footer">
|
<li><a href="#work">Work</a></li>
|
||||||
<a className="hoverable" href="#services">Services</a>
|
<li><a href="#about">About</a></li>
|
||||||
<a className="hoverable" href="#work">Work</a>
|
<li><a href="#faq">FAQ</a></li>
|
||||||
<a className="hoverable" href="#process">About</a>
|
<li><a href={`mailto:${SITE.email}`}>Contact</a></li>
|
||||||
<a className="hoverable" href="#faq">FAQ</a>
|
</ul>
|
||||||
<a className="hoverable" href="#contact">Contact</a>
|
<ul>
|
||||||
|
<li><a href="https://www.linkedin.com" rel="noopener">LinkedIn</a></li>
|
||||||
|
<li><a href={SITE.booking}>Book a call</a></li>
|
||||||
|
<li><a href="#main">Privacy</a></li>
|
||||||
|
<li><a href="#main">Terms</a></li>
|
||||||
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rule" />
|
||||||
<div className="site-footer__col">
|
<div className="colophon__bottom">
|
||||||
<h2>Connect</h2>
|
<p>© 2026 {SITE.name}. All rights reserved.</p>
|
||||||
<nav aria-label="Social and legal">
|
<p className="colophon__note">
|
||||||
<a className="hoverable" href="https://www.linkedin.com" rel="noopener">LinkedIn</a>
|
Metrics and case studies shown are illustrative samples.
|
||||||
<a className="hoverable" href="mailto:hello@feedbackstudios.com">Email</a>
|
|
||||||
<a className="hoverable" href="#">Privacy</a>
|
|
||||||
<a className="hoverable" href="#">Terms</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="site-footer__legal">
|
|
||||||
<span>© 2026 Feedback Studios</span>
|
|
||||||
<span>Marketing that grows your revenue.</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue