feat: 'Revenue Terminal' — modern animated home (Motion+GSAP DrawSVG/MorphSVG, hero video, real interactions)

This commit is contained in:
Feedback Studios 2026-06-16 07:11:46 +00:00
parent 30bed043ec
commit 80f46b9780
28 changed files with 2043 additions and 1636 deletions

View file

@ -1,103 +0,0 @@
"use client";
import { useRef, useState } from "react";
/**
* Interactive "before / after" revenue slider the visual proof-of-value
* pattern. A draggable divider wipes a muted "before" chart to reveal a
* surging acid "after" chart. Fully keyboard operable via a real range
* input (arrow keys). The headline result is always visible as text, so
* meaning never depends on the interaction.
*/
export default function BeforeAfter({
before,
after,
caption,
}: {
before: string;
after: string;
caption: string;
}) {
const [pos, setPos] = useState(38);
const wrap = useRef<HTMLDivElement>(null);
const id = `ba-${caption.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
const drag = (clientX: number) => {
const r = wrap.current?.getBoundingClientRect();
if (!r) return;
const p = ((clientX - r.left) / r.width) * 100;
setPos(Math.max(2, Math.min(98, p)));
};
return (
<figure className="ba">
<div
className="ba__stage"
ref={wrap}
onPointerMove={(e) => e.buttons === 1 && drag(e.clientX)}
onPointerDown={(e) => drag(e.clientX)}
>
{/* AFTER (full) — surging line */}
<div className="ba__layer ba__after" aria-hidden="true">
<Chart variant="after" />
<span className="ba__tag ba__tag--after">{after}</span>
</div>
{/* BEFORE (clipped to slider) — flat, muted */}
<div
className="ba__layer ba__before"
style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }}
aria-hidden="true"
>
<Chart variant="before" />
<span className="ba__tag ba__tag--before">{before}</span>
</div>
{/* divider + accessible control */}
<div className="ba__divider" style={{ left: `${pos}%` }} aria-hidden="true">
<span className="ba__handle">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 6 4 12l5 6M15 6l5 6-5 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</span>
</div>
<label className="sr-only" htmlFor={id}>
Reveal results for {caption}: drag to compare before and after
</label>
<input
id={id}
className="ba__range"
type="range"
min={2}
max={98}
value={pos}
onChange={(e) => setPos(Number(e.target.value))}
/>
</div>
<figcaption className="ba__cap">{caption}</figcaption>
</figure>
);
}
/** SVG sparkline. "before" = flat/jagged & muted, "after" = steep climb + acid. */
function Chart({ variant }: { variant: "before" | "after" }) {
const after = variant === "after";
const path = after
? "M0 86 C 40 84, 70 80, 110 70 S 190 30, 240 10"
: "M0 70 C 40 72, 70 66, 110 70 S 190 64, 240 62";
return (
<svg
className={`ba__chart ba__chart--${variant}`}
viewBox="0 0 240 100"
preserveAspectRatio="none"
>
<defs>
<linearGradient id={`g-${variant}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={after ? "var(--c-acid)" : "currentColor"} stopOpacity={after ? 0.35 : 0.12} />
<stop offset="1" stopColor={after ? "var(--c-acid)" : "currentColor"} stopOpacity="0" />
</linearGradient>
</defs>
<path d={`${path} L 240 100 L 0 100 Z`} fill={`url(#g-${variant})`} stroke="none" />
<path d={path} fill="none" stroke={after ? "var(--c-acid)" : "currentColor"} strokeWidth={after ? 2.5 : 1.5} strokeLinecap="round" vectorEffect="non-scaling-stroke" />
</svg>
);
}

125
app/components/CaseCard.tsx Normal file
View file

@ -0,0 +1,125 @@
"use client";
/**
* CASE CARD replaces the rejected repetitive photos with a coded, animated
* data visual unique per case:
* - 3D tilt that tracks the pointer (Motion springs, transform-only).
* - An animated bars/sparkline "result chart" drawn in CSS + SVG, growing
* into view (no two cards look alike: different bars, colours, labels).
* - A glare/sheen that follows the cursor across the surface.
* Reduced-motion / touch: flat card, bars still grow on view via CSS.
*/
import { useRef } from "react";
import {
motion,
useMotionValue,
useSpring,
useTransform,
useReducedMotion,
} from "motion/react";
export type CaseData = {
tag: string;
problem: string;
result: string;
how: string;
metricNum: string;
metricLabel: string;
bars: number[]; // 0..100 heights — unique per case
accent: string; // brand accent for this card
};
export default function CaseCard({ data, index }: { data: CaseData; index: number }) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const mx = useMotionValue(0.5);
const my = useMotionValue(0.5);
const rx = useSpring(useTransform(my, [0, 1], [7, -7]), { stiffness: 200, damping: 20 });
const ry = useSpring(useTransform(mx, [0, 1], [-9, 9]), { stiffness: 200, damping: 20 });
const glare = useTransform(
[mx, my],
([gx, gy]: number[]) =>
`radial-gradient(circle at ${gx * 100}% ${gy * 100}%, rgba(255,255,255,0.14), transparent 45%)`
);
const onMove = (e: React.PointerEvent) => {
if (reduce || e.pointerType !== "mouse") return;
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
mx.set((e.clientX - r.left) / r.width);
my.set((e.clientY - r.top) / r.height);
};
const reset = () => {
mx.set(0.5);
my.set(0.5);
};
return (
<motion.article
ref={ref}
className="case"
onPointerMove={onMove}
onPointerLeave={reset}
style={reduce ? undefined : { rotateX: rx, rotateY: ry, transformPerspective: 1000 }}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
>
<div className="case__inner" style={{ ["--case-accent" as string]: data.accent }}>
<div className="case__head">
<span className="case__no">{String(index + 1).padStart(2, "0")}</span>
<span className="case__tag">{data.tag}</span>
</div>
{/* coded data visual — unique bars per case */}
<div className="case__viz" aria-hidden="true">
<div className="case__bars">
{data.bars.map((h, i) => (
<span
key={i}
className="case__bar"
style={{
["--h" as string]: `${h}%`,
["--d" as string]: `${i * 70}ms`,
}}
/>
))}
</div>
<svg className="case__spark" viewBox="0 0 120 40" preserveAspectRatio="none">
<polyline
points="0,34 24,30 48,22 72,18 96,8 120,4"
fill="none"
stroke="var(--case-accent)"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
<div className="case__body">
<p className="case__problem">
<span className="case__k">Before</span>
{data.problem}
</p>
<p className="case__result display">{data.result}</p>
<p className="case__how">{data.how}</p>
</div>
<div className="case__metric">
<span className="case__metric-num display">{data.metricNum}</span>
<span className="case__metric-lab">{data.metricLabel}</span>
</div>
{!reduce && (
<motion.span
className="case__glare"
aria-hidden="true"
style={{ background: glare }}
/>
)}
</div>
</motion.article>
);
}

View file

@ -1,68 +1,55 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Props = {
to: number;
prefix?: string;
suffix?: string;
decimals?: number;
duration?: number;
};
/**
* Count-up that fires once when scrolled into view. Renders the final
* value immediately under reduced motion (and as SSR fallback) so the
* real number is always present for assistive tech and no-JS.
* Animated number that counts up when it enters the viewport (once).
* - Motion `animate()` drives a raw value; we format with prefix/suffix/decimals.
* - prefers-reduced-motion: jumps straight to the final value.
* - Real number text stays in the DOM for SEO / screen readers.
*/
import { useEffect, useRef, useState } from "react";
import { animate, useInView, useReducedMotion } from "motion/react";
export default function CountUp({
to,
prefix = "",
suffix = "",
decimals = 0,
duration = 1600,
}: Props) {
duration = 1.8,
}: {
to: number;
prefix?: string;
suffix?: string;
decimals?: number;
duration?: number;
}) {
const ref = useRef<HTMLSpanElement>(null);
const [val, setVal] = useState(to);
const done = useRef(false);
const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" });
const reduce = useReducedMotion();
const [val, setVal] = useState(0);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!inView) return;
if (reduce) {
setVal(to);
return;
}
setVal(0);
const node = ref.current;
if (!node) return;
const controls = animate(0, to, {
duration,
ease: [0.16, 1, 0.3, 1],
onUpdate: (v) => setVal(v),
});
return () => controls.stop();
}, [inView, to, duration, reduce]);
const io = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting || done.current) return;
done.current = true;
const start = performance.now();
const tick = (now: number) => {
const p = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
setVal(to * eased);
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
io.disconnect();
},
{ threshold: 0.4 }
);
io.observe(node);
return () => io.disconnect();
}, [to, duration]);
const display = val.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return (
<span ref={ref}>
{prefix}
{val.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}
{display}
{suffix}
</span>
);

View file

@ -1,64 +1,68 @@
"use client";
import { useEffect, useRef } from "react";
/**
* A small "terminal crosshair" cursor that grows into a reading ring over
* interactive targets. Pointer-device only; hidden for touch / reduced
* motion. Purely decorative (aria-hidden) never the only affordance.
* Custom cursor: a spring-following dot + a larger ring.
* - Grows + shows a contextual label when hovering [data-cursor] targets.
* - Hidden on touch / coarse pointers and when prefers-reduced-motion is set
* (falls back to the native cursor, which globals.css restores).
* - Built with Motion springs for buttery follow without layout thrash
* (transform-only, GPU-friendly).
*/
import { useEffect, useState } from "react";
import { motion, useMotionValue, useSpring } from "motion/react";
export default function Cursor() {
const ring = useRef<HTMLDivElement>(null);
const label = useRef<HTMLSpanElement>(null);
const [enabled, setEnabled] = useState(false);
const [hovering, setHovering] = useState(false);
const [label, setLabel] = useState("");
const x = useMotionValue(-100);
const y = useMotionValue(-100);
const ringX = useSpring(x, { stiffness: 350, damping: 30, mass: 0.6 });
const ringY = useSpring(y, { stiffness: 350, damping: 30, mass: 0.6 });
const dotX = useSpring(x, { stiffness: 900, damping: 40 });
const dotY = useSpring(y, { stiffness: 900, damping: 40 });
useEffect(() => {
const fine = window.matchMedia("(pointer: fine)").matches;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!fine || reduce) return;
const el = ring.current;
const lab = label.current;
if (!el || !lab) return;
let x = window.innerWidth / 2;
let y = window.innerHeight / 2;
let cx = x;
let cy = y;
let raf = 0;
setEnabled(true);
document.documentElement.classList.add("has-custom-cursor");
const move = (e: PointerEvent) => {
x = e.clientX;
y = e.clientY;
const t = e.target as HTMLElement;
const tgt = t?.closest("a, button, [data-cursor]");
el.dataset.active = tgt ? "1" : "0";
const txt = (tgt as HTMLElement)?.dataset?.cursor;
lab.textContent = txt || "";
el.dataset.hasLabel = txt ? "1" : "0";
x.set(e.clientX);
y.set(e.clientY);
const target = (e.target as HTMLElement)?.closest<HTMLElement>(
"[data-cursor], a, button"
);
if (target) {
setHovering(true);
setLabel(target.getAttribute("data-cursor") || "");
} else {
setHovering(false);
setLabel("");
}
};
const loop = () => {
cx += (x - cx) * 0.2;
cy += (y - cy) * 0.2;
el.style.transform = `translate3d(${cx}px, ${cy}px, 0) translate(-50%, -50%)`;
raf = requestAnimationFrame(loop);
};
document.body.classList.add("has-cursor");
window.addEventListener("pointermove", move);
raf = requestAnimationFrame(loop);
window.addEventListener("pointermove", move, { passive: true });
return () => {
window.removeEventListener("pointermove", move);
cancelAnimationFrame(raf);
document.body.classList.remove("has-cursor");
document.documentElement.classList.remove("has-custom-cursor");
};
}, []);
}, [x, y]);
if (!enabled) return null;
return (
<div ref={ring} className="cursor" aria-hidden="true" data-active="0">
<span className="cursor__cross" />
<span ref={label} className="cursor__label" />
<div aria-hidden="true" className="cursor-layer">
<motion.div
className={`cursor-ring ${hovering ? "is-hover" : ""} ${label ? "is-labelled" : ""}`}
style={{ x: ringX, y: ringY }}
>
{label && <span className="cursor-ring__label">{label}</span>}
</motion.div>
<motion.div className="cursor-dot" style={{ x: dotX, y: dotY }} />
</div>
);
}

View file

@ -1,23 +1,27 @@
"use client";
/**
* FAQ accordion accessible disclosure pattern.
* - Real <button aria-expanded aria-controls> toggles each panel.
* - Panel height animates via Motion (height auto), with a reduced-motion guard.
* - Single-open behaviour; arrow rotates; keyboard + screen-reader friendly.
* The full Q&A text is always in the DOM (good for AEO / FAQPage schema).
*/
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { faqs } from "../content";
/**
* Accessible accordion built on native <button>. Uses aria-expanded +
* aria-controls; panels are real content (always in DOM for SEO/AEO),
* height-animated via grid-template-rows. One open at a time.
*/
export default function Faq() {
const [open, setOpen] = useState<number | null>(0);
const reduce = useReducedMotion();
return (
<div className="faq">
<ul className="faq">
{faqs.map((f, i) => {
const isOpen = open === i;
return (
<div className="faq__item" key={i}>
<h3 className="faq__h">
<li className={`faq__item ${isOpen ? "is-open" : ""}`} key={f.q}>
<h3 className="faq__q">
<button
className="faq__btn"
aria-expanded={isOpen}
@ -25,27 +29,32 @@ export default function Faq() {
id={`faq-btn-${i}`}
onClick={() => setOpen(isOpen ? null : i)}
>
<span className="faq__num">Q{i + 1}</span>
<span className="faq__q">{f.q}</span>
<span className="faq__sign" aria-hidden="true" data-open={isOpen}>
<i /><i />
<span>{f.q}</span>
<span className="faq__icon" aria-hidden="true">
<span />
<span />
</span>
</button>
</h3>
<div
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={`faq-panel-${i}`}
role="region"
aria-labelledby={`faq-btn-${i}`}
className="faq__panel"
data-open={isOpen}
initial={reduce ? { height: "auto", opacity: 1 } : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { height: "auto", opacity: 1 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
>
<div className="faq__panel-inner">
<p>{f.a}</p>
</div>
</div>
</div>
<p className="faq__a">{f.a}</p>
</motion.div>
)}
</AnimatePresence>
</li>
);
})}
</div>
</ul>
);
}

238
app/components/Hero.tsx Normal file
View file

@ -0,0 +1,238 @@
"use client";
/**
* HERO the signature moment. Fixes the client's 4 complaints head-on:
* (2) real background VIDEO (autoplay/muted/loop/playsinline, poster, cover,
* deferred load, reduced-motion -> poster still only).
* (3) ANIMATED SVG: a revenue growth line that draws itself (GSAP DrawSVG)
* with an area fill that fades up and a pulsing "live" marker.
* (1) real interaction: SplitText word reveal, cursor spotlight following the
* pointer, magnetic CTAs, parallax on scroll.
* (4) zero raster imagery beyond the video poster.
*/
import { useEffect, useRef } from "react";
import Link from "next/link";
import { gsap, SplitText } from "./gsap";
import Magnetic from "./Magnetic";
import { SITE } from "../content";
export default function Hero() {
const root = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
// Pointer spotlight (CSS vars on the hero -> radial-gradient follows cursor).
const onPointerMove = (e: React.PointerEvent) => {
const el = root.current;
if (!el) return;
const r = el.getBoundingClientRect();
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`);
};
useEffect(() => {
const el = root.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Defer the video: only start it once it's actually on screen + ready,
// and pause it when the tab/section is hidden (saves battery + main thread).
const video = videoRef.current;
if (video && !reduce) {
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) video.play().catch(() => {});
else video.pause();
},
{ threshold: 0.1 }
);
io.observe(video);
const onVis = () => {
if (document.hidden) video.pause();
else if (video.getBoundingClientRect().top < window.innerHeight) video.play().catch(() => {});
};
document.addEventListener("visibilitychange", onVis);
const ctx = gsap.context(() => {
// 1) Headline: split into words/lines and reveal with a mask.
const split = new SplitText(".hero__h1", { type: "lines,words" });
gsap.set(".hero__h1", { autoAlpha: 1 });
gsap.from(split.words, {
yPercent: 120,
opacity: 0,
duration: 1,
ease: "expo.out",
stagger: 0.04,
delay: 0.15,
});
// 2) Staggered entrance for eyebrow, sub, CTAs, trust row.
gsap.from(".hero__stagger", {
y: 24,
opacity: 0,
duration: 0.9,
ease: "expo.out",
stagger: 0.08,
delay: 0.5,
});
// 3) ANIMATED SVG — the growth line draws itself, area fades in,
// grid ticks pop, live marker pulses.
gsap.set(".hero-svg__area", { autoAlpha: 0 });
const tl = gsap.timeline({ delay: 0.4 });
tl.from(".hero-svg__grid line", {
drawSVG: "0%",
duration: 0.8,
stagger: 0.04,
ease: "power2.out",
})
.from(
".hero-svg__line",
{ drawSVG: "0%", duration: 1.8, ease: "power2.inOut" },
"-=0.4"
)
.to(".hero-svg__area", { autoAlpha: 1, duration: 0.9 }, "-=1.1")
.from(
".hero-svg__dot",
{ scale: 0, transformOrigin: "center", duration: 0.5, ease: "back.out(2)" },
"-=0.5"
);
// 4) Parallax: video drifts slower than content as you scroll away.
gsap.to(".hero__media", {
yPercent: 18,
ease: "none",
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true },
});
gsap.to(".hero__content", {
yPercent: -8,
opacity: 0.4,
ease: "none",
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true },
});
}, el);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
ctx.revert();
};
}
// Reduced motion: still reveal the SVG line statically (no draw), show poster.
gsap.set([".hero__h1", ".hero__stagger", ".hero-svg__area"], { autoAlpha: 1 });
}, []);
return (
<section
ref={root}
className="hero"
aria-labelledby="hero-h1"
onPointerMove={onPointerMove}
>
{/* ---- background video (deferred) + poster + scrims ---- */}
<div className="hero__media" aria-hidden="true">
<video
ref={videoRef}
className="hero__video"
poster="/assets/hero-poster.jpg"
muted
loop
playsInline
preload="none"
>
<source src="/assets/hero.mp4" type="video/mp4" />
</video>
<div className="hero__scrim" />
<div className="hero__spotlight" />
</div>
{/* ---- animated SVG growth chart ---- */}
<svg
className="hero-svg"
viewBox="0 0 1200 600"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
>
<defs>
<linearGradient id="heroLine" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="55%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
<linearGradient id="heroArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#8b5cf6" stopOpacity="0.35" />
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0" />
</linearGradient>
</defs>
<g className="hero-svg__grid">
<line x1="0" y1="150" x2="1200" y2="150" />
<line x1="0" y1="300" x2="1200" y2="300" />
<line x1="0" y1="450" x2="1200" y2="450" />
</g>
{/* area under the curve */}
<path
className="hero-svg__area"
fill="url(#heroArea)"
d="M0,520 C160,520 220,470 340,430 C470,388 520,300 660,290 C820,278 880,160 1040,120 C1110,102 1160,92 1200,86 L1200,600 L0,600 Z"
/>
{/* the revenue line that draws itself */}
<path
className="hero-svg__line"
fill="none"
stroke="url(#heroLine)"
strokeWidth="4"
strokeLinecap="round"
d="M0,520 C160,520 220,470 340,430 C470,388 520,300 660,290 C820,278 880,160 1040,120 C1110,102 1160,92 1200,86"
/>
<circle className="hero-svg__dot" cx="1200" cy="86" r="9" fill="#10b981" />
</svg>
{/* ---- content ---- */}
<div className="hero__content wrap">
<p className="hero__eyebrow hero__stagger">
<span className="hero__live" aria-hidden="true" />
Results-driven digital marketing agency
</p>
<h1 id="hero-h1" className="hero__h1">
Marketing that grows your <span className="grad">revenue.</span>
</h1>
<p className="hero__sub hero__stagger">
We run paid, SEO, and content programs built around your revenue
targets, then show you exactly what they returned. Every month.
</p>
<div className="hero__cta hero__stagger">
<Magnetic strength={0.4}>
<Link href={SITE.booking} className="btn btn--accent" data-cursor="Book it">
Get your growth audit
</Link>
</Magnetic>
<Magnetic strength={0.3}>
<a href="#work" className="btn btn--ghost" data-cursor="See proof">
See the results <span className="arrow" aria-hidden="true"></span>
</a>
</Magnetic>
</div>
<dl className="hero__trust hero__stagger">
<div>
<dt>$40M+</dt>
<dd>in client revenue generated</dd>
</div>
<div className="hero__trust-div" aria-hidden="true" />
<div>
<dt>50+</dt>
<dd>brands grown</dd>
</div>
</dl>
</div>
<a href="#manifesto" className="hero__scroll hero__stagger" aria-label="Scroll to content">
<span className="hero__scroll-line" aria-hidden="true" />
Scroll
</a>
</section>
);
}

View file

@ -1,64 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import SplitType from "split-type";
/**
* Splits the hero H1 into lines + chars and reveals them with a staggered
* mask-up on load the orchestrated "page load" delight moment.
* Under reduced motion the text is simply shown as-is. The real text is
* always in the DOM (SplitType only re-wraps the same characters), so SEO
* and screen readers read the full heading.
*/
export default function KineticHeadline({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
el.style.opacity = "1";
return;
}
const split = new SplitType(el, { types: "lines,words" });
el.style.opacity = "1";
// wrap each line in an overflow-hidden mask
split.lines?.forEach((line) => {
const mask = document.createElement("span");
mask.className = "line-mask";
line.parentNode?.insertBefore(mask, line);
mask.appendChild(line);
});
const tween = gsap.from(split.words || [], {
yPercent: 115,
opacity: 0,
rotateZ: 2,
duration: 1,
ease: "expo.out",
stagger: 0.04,
delay: 0.15,
});
return () => {
tween.kill();
split.revert();
};
}, []);
return (
<h1 ref={ref} className={className} style={{ opacity: 0 }}>
{children}
</h1>
);
}

View file

@ -0,0 +1,53 @@
"use client";
/**
* Magnetic wrapper: the child is pulled toward the cursor while hovered,
* then springs back on leave. Transform-only (GPU). No-op on coarse pointers
* and with prefers-reduced-motion, so keyboard/touch users get a static,
* fully-clickable element.
*/
import { useRef } from "react";
import { motion, useMotionValue, useSpring } from "motion/react";
export default function Magnetic({
children,
strength = 0.4,
className,
}: {
children: React.ReactNode;
strength?: number;
className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, { stiffness: 250, damping: 18, mass: 0.4 });
const sy = useSpring(y, { stiffness: 250, damping: 18, mass: 0.4 });
const onMove = (e: React.PointerEvent) => {
if (e.pointerType !== "mouse") return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
x.set((e.clientX - (r.left + r.width / 2)) * strength);
y.set((e.clientY - (r.top + r.height / 2)) * strength);
};
const reset = () => {
x.set(0);
y.set(0);
};
return (
<motion.span
ref={ref}
className={className}
onPointerMove={onMove}
onPointerLeave={reset}
style={{ x: sx, y: sy, display: "inline-block" }}
>
{children}
</motion.span>
);
}

View file

@ -0,0 +1,133 @@
"use client";
/**
* PROCESS "The Feedback Loop" as pinned scroll storytelling.
* - GSAP ScrollTrigger pins the panel while the user scrolls through 4 steps.
* - A single SVG glyph MORPHS between 4 shapes (MorphSVGPlugin) magnifier
* (Audit) -> route/plan -> bolt (Execute) -> chart (Report) the animated-SVG
* moment for this section.
* - A progress rail + numbered steps light up in sync (scrub).
* - prefers-reduced-motion: no pin, no morph; steps render as a static list.
*/
import { useEffect, useRef } from "react";
// Side-effect import registers ScrollTrigger + MorphSVGPlugin; gsap exposes both.
import { gsap } from "./gsap";
import { processSteps } from "../content";
// Four target paths for the morph (drawn on a 100x100 canvas).
const SHAPES = [
// Audit — magnifying glass
"M44 20a24 24 0 1 0 15 43l18 18 7-7-18-18A24 24 0 0 0 44 20Zm0 10a14 14 0 1 1 0 28 14 14 0 0 1 0-28Z",
// Plan — connected route / nodes
"M22 30a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm40 40a8 8 0 1 1 16 0 8 8 0 0 1-16 0ZM30 38v8a16 16 0 0 0 16 16h8a16 16 0 0 1 16 16v-2 2h-8a26 26 0 0 1-26-26v-8Z",
// Execute — lightning bolt
"M55 14 26 58h20l-6 30 32-46H50l9-28Z",
// Report — bar chart trending up
"M22 78V52h12v26Zm22 0V36h12v42Zm22 0V20h12v58ZM20 30 40 22l16 8 26-14",
];
export default function ProcessLoop() {
const root = useRef<HTMLElement>(null);
useEffect(() => {
const el = root.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const ctx = gsap.context(() => {
const morph = el.querySelector<SVGPathElement>(".loop-glyph__path");
if (!morph) return;
const steps = gsap.utils.toArray<HTMLElement>(".loop-step");
const total = SHAPES.length;
// One pinned, scrubbed timeline drives the morph + rotation + rail fill.
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".loop-pin",
start: "top top",
end: () => `+=${window.innerHeight * (total - 0.2)}`,
pin: true,
scrub: 0.6,
anticipatePin: 1,
onUpdate: (self) => {
// Robust, independent active-step highlight tied to raw progress.
const idx = Math.min(
total - 1,
Math.floor(self.progress * total + 0.0001)
);
steps.forEach((s, j) => s.classList.toggle("is-active", j === idx));
},
},
});
steps[0]?.classList.add("is-active");
SHAPES.forEach((shape, i) => {
if (i === 0) return; // start shape already in markup
const at = i - 1;
tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at)
.to(".loop-glyph", { rotate: i * 6, duration: 1, ease: "power2.inOut" }, at)
.to(
".loop-rail__fill",
{ scaleY: i / (total - 1), duration: 1, ease: "none" },
at
);
});
}, el);
return () => ctx.revert();
}, []);
return (
<section ref={root} id="process" className="loop frame" data-invert aria-labelledby="loop-h">
<div className="loop-pin">
<div className="wrap loop__inner">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
The Feedback Loop
</p>
<h2 id="loop-h" className="display sec-head__title">
How it works
</h2>
</header>
<div className="loop__stage">
{/* morphing glyph */}
<div className="loop-glyph" aria-hidden="true">
<svg viewBox="0 0 100 100">
<path className="loop-glyph__path" d={SHAPES[0]} fill="url(#loopGrad)" />
<defs>
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="50%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
</defs>
</svg>
</div>
{/* steps + progress rail */}
<div className="loop__steps">
<div className="loop-rail" aria-hidden="true">
<span className="loop-rail__fill" />
</div>
<ol>
{processSteps.map((p) => (
<li className="loop-step" key={p.n}>
<span className="loop-step__n">{p.n}</span>
<div>
<h3 className="loop-step__name">{p.name}</h3>
<p className="loop-step__desc">{p.desc}</p>
</div>
</li>
))}
</ol>
</div>
</div>
</div>
</div>
</section>
);
}

View file

@ -1,49 +1,86 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Lightweight scroll reveal via IntersectionObserver (no GSAP dependency
* so it works even before the smooth-scroll module mounts). Adds .is-in.
* CSS already shows content under reduced motion.
* In-view reveal. Fades + lifts content when it scrolls into view (once).
* - Motion `whileInView` with a viewport margin so it triggers slightly early.
* - `stagger` cascades direct children via Motion variants.
* - Honors prefers-reduced-motion (renders fully visible, no transform).
*/
export default function Reveal({
children,
as: Tag = "div",
className = "",
delay = 0,
}: {
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "motion/react";
type Props = {
children: React.ReactNode;
as?: keyof React.JSX.IntrinsicElements;
className?: string;
delay?: number;
}) {
const ref = useRef<HTMLElement>(null);
y?: number;
stagger?: number;
as?: "div" | "section" | "ul" | "ol" | "li" | "p" | "figure" | "header";
};
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
el.classList.add("is-in");
io.disconnect();
}
},
{ threshold: 0.15, rootMargin: "0px 0px -8% 0px" }
);
io.observe(el);
return () => io.disconnect();
}, []);
export default function Reveal({
children,
className,
delay = 0,
y = 26,
stagger,
as = "div",
}: Props) {
const reduce = useReducedMotion();
const MotionTag = motion[as] as typeof motion.div;
const Comp = Tag as React.ElementType;
if (stagger) {
const parent: Variants = {
hidden: {},
show: { transition: { staggerChildren: reduce ? 0 : stagger, delayChildren: delay } },
};
return (
<Comp
ref={ref}
className={`reveal ${className}`}
style={{ transitionDelay: `${delay}ms` }}
<MotionTag
className={className}
variants={parent}
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
>
{children}
</Comp>
</MotionTag>
);
}
return (
<MotionTag
className={className}
initial={reduce ? { opacity: 1 } : { opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
transition={{ duration: 0.7, delay: delay / 1000, ease: [0.16, 1, 0.3, 1] }}
>
{children}
</MotionTag>
);
}
/** A child item that participates in a parent <Reveal stagger>. */
export function RevealItem({
children,
className,
y = 22,
as = "div",
}: {
children: React.ReactNode;
className?: string;
y?: number;
as?: "div" | "li" | "p";
}) {
const reduce = useReducedMotion();
const MotionTag = motion[as] as typeof motion.div;
const item: Variants = {
hidden: reduce ? { opacity: 1 } : { opacity: 0, y },
show: { opacity: 1, y: 0, transition: { duration: 0.65, ease: [0.16, 1, 0.3, 1] } },
};
return (
<MotionTag className={className} variants={item}>
{children}
</MotionTag>
);
}

View file

@ -1,206 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { Renderer, Program, Mesh, Triangle, Vec2 } from "ogl";
/**
* Custom WebGL signature: a flowing "revenue field" animated contour
* lines that rise and surge like a growth chart / topographic gain map,
* rendered as ink hairlines on paper with an acid-green crest threading
* through. Reacts gently to the pointer.
*
* Constraints honored:
* - "use client", all GL created in useEffect, full cleanup
* - no window/document access during SSR/render
* - pauses RAF when the tab is hidden or canvas is offscreen
* - respects prefers-reduced-motion (renders one static frame, no loop)
* - decorative only (aria-hidden); never the sole carrier of meaning
*/
export default function RevenueField() {
const wrap = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = wrap.current;
if (!host) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const renderer = new Renderer({
alpha: true,
antialias: true,
dpr: Math.min(window.devicePixelRatio || 1, 2),
});
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
host.appendChild(gl.canvas);
gl.canvas.style.width = "100%";
gl.canvas.style.height = "100%";
gl.canvas.style.display = "block";
const geometry = new Triangle(gl);
const program = new Program(gl, {
uniforms: {
uTime: { value: 0 },
uRes: { value: new Vec2(1, 1) },
uMouse: { value: new Vec2(0.5, 0.5) },
// ink + emerald crest (the hero sits on light paper, so the crest
// uses the deeper "gain" green to stay perceptible, not invisible lime)
uInk: { value: [0.078, 0.075, 0.059] },
uAcid: { value: [0.039, 0.431, 0.278] },
},
vertex: /* glsl */ `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`,
fragment: /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform vec2 uRes;
uniform vec2 uMouse;
uniform vec3 uInk;
uniform vec3 uAcid;
// cheap value noise
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p){
vec2 i = floor(p); vec2 f = fract(p);
vec2 u = f*f*(3.0-2.0*f);
return mix(mix(hash(i), hash(i+vec2(1,0)), u.x),
mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), u.x), u.y);
}
float fbm(vec2 p){
float v = 0.0; float a = 0.5;
for(int i=0;i<5;i++){ v += a*noise(p); p *= 2.0; a *= 0.5; }
return v;
}
void main() {
vec2 uv = vUv;
float aspect = uRes.x / max(uRes.y, 1.0);
vec2 p = uv;
p.x *= aspect;
// mouse influence — a gentle lift around the pointer
vec2 m = uMouse; m.x *= aspect;
float md = distance(p, m);
float lift = smoothstep(0.6, 0.0, md) * 0.18;
float t = uTime * 0.06;
// a rising surface: base height climbs left->right (growth)
float climb = uv.x * 0.55;
float field = fbm(p * 2.4 + vec2(t, t * 0.4)) + climb + lift;
field += fbm(p * 5.0 - vec2(t * 0.7, 0.0)) * 0.25;
// contour lines from the height field
float lines = abs(fract(field * 9.0) - 0.5);
float w = fwidth(field * 9.0) * 1.2;
float contour = 1.0 - smoothstep(0.0, w, lines);
// crest highlight: the topmost band glows acid (the "gain")
float crest = smoothstep(0.62, 0.86, field) * smoothstep(0.96, 0.7, field);
vec3 col = mix(uInk, uAcid, crest * 0.9);
float alpha = contour * (0.20 + crest * 0.85);
// soft right/top fade so type stays readable, lines vignette off edges
alpha *= smoothstep(0.0, 0.18, uv.x);
alpha *= smoothstep(0.0, 0.14, uv.y) * smoothstep(1.0, 0.82, uv.y);
gl_FragColor = vec4(col, alpha);
}
`,
});
const mesh = new Mesh(gl, { geometry, program });
const resize = () => {
const w = host.clientWidth;
const h = host.clientHeight;
renderer.setSize(w, h);
program.uniforms.uRes.value.set(w, h);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(host);
// pointer (eased)
const target = new Vec2(0.5, 0.5);
const onPointer = (e: PointerEvent) => {
const r = host.getBoundingClientRect();
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
};
window.addEventListener("pointermove", onPointer);
let raf = 0;
let running = true;
const start = performance.now();
const frame = (now: number) => {
if (!running) return;
const u = program.uniforms;
u.uTime.value = (now - start) / 1000;
(u.uMouse.value as Vec2).x += (target.x - (u.uMouse.value as Vec2).x) * 0.05;
(u.uMouse.value as Vec2).y += (target.y - (u.uMouse.value as Vec2).y) * 0.05;
renderer.render({ scene: mesh });
raf = requestAnimationFrame(frame);
};
// visibility: pause GL when tab hidden or canvas scrolled offscreen
const onVisibility = () => {
if (document.hidden) {
running = false;
cancelAnimationFrame(raf);
} else if (!reduce) {
running = true;
raf = requestAnimationFrame(frame);
}
};
document.addEventListener("visibilitychange", onVisibility);
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !document.hidden && !reduce) {
if (!running) {
running = true;
raf = requestAnimationFrame(frame);
}
} else {
running = false;
cancelAnimationFrame(raf);
}
},
{ threshold: 0 }
);
io.observe(host);
if (reduce) {
// single static frame, no animation loop
program.uniforms.uTime.value = 8;
renderer.render({ scene: mesh });
} else {
raf = requestAnimationFrame(frame);
}
return () => {
running = false;
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", onPointer);
document.removeEventListener("visibilitychange", onVisibility);
io.disconnect();
ro.disconnect();
const ext = gl.getExtension("WEBGL_lose_context");
if (ext) ext.loseContext();
if (gl.canvas.parentNode) gl.canvas.parentNode.removeChild(gl.canvas);
};
}, []);
return <div ref={wrap} className="revfield" aria-hidden="true" />;
}

View file

@ -0,0 +1,94 @@
"use client";
/**
* METRICS scoreboard.
* - CountUp numbers animate on view (Motion).
* - A second ANIMATED-SVG moment: a bar chart whose bars DRAW/grow + a baseline
* that draws itself (GSAP DrawSVG) when the section scrolls in.
* Reduced-motion: numbers jump to final, bars render at full height (CSS).
*/
import { useEffect, useRef } from "react";
import { gsap } from "./gsap";
import CountUp from "./CountUp";
import { metrics } from "../content";
const BARS = [38, 56, 72, 64, 88, 96]; // decorative growth bars (0..100)
export default function Scoreboard() {
const root = useRef<HTMLElement>(null);
useEffect(() => {
const el = root.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const ctx = gsap.context(() => {
const tl = gsap.timeline({
scrollTrigger: { trigger: ".score__chart", start: "top 80%", once: true },
});
tl.from(".score-bar", {
scaleY: 0,
transformOrigin: "bottom",
duration: 0.9,
ease: "expo.out",
stagger: 0.08,
}).from(
".score__baseline",
{ drawSVG: "0%", duration: 0.9, ease: "power2.out" },
"-=0.7"
);
}, el);
return () => ctx.revert();
}, []);
return (
<section ref={root} data-invert className="score frame" aria-labelledby="score-h">
<div className="wrap">
<header className="sec-head sec-head--center">
<p className="kicker">
<span className="kicker__dot" />
The scoreboard sample data
</p>
<h2 id="score-h" className="display sec-head__title">
Numbers we report on
</h2>
</header>
<dl className="score__grid">
{metrics.map((m) => (
<div className="score__cell" key={m.label}>
<dt className="sr-only">{m.label}</dt>
<dd
className={`score__num display ${"accent" in m && m.accent ? "is-accent" : ""}`}
>
<CountUp
to={m.value}
prefix={"prefix" in m ? m.prefix : ""}
suffix={m.suffix}
decimals={"decimals" in m ? m.decimals : 0}
/>
</dd>
<p className="score__lab" aria-hidden="true">
{m.label}
</p>
</div>
))}
</dl>
{/* decorative animated bar chart */}
<div className="score__chart" aria-hidden="true">
<div className="score__bars">
{BARS.map((h, i) => (
<span key={i} className="score-bar" style={{ height: `${h}%` }} />
))}
</div>
<svg className="score__axis" viewBox="0 0 600 8" preserveAspectRatio="none">
<line className="score__baseline" x1="0" y1="4" x2="600" y2="4" />
</svg>
</div>
</div>
</section>
);
}

View file

@ -1,104 +1,116 @@
"use client";
import { useEffect, useState } from "react";
/**
* Sticky header.
* - Condenses (smaller, blurred, bordered) after the hero via scroll listener.
* - Top scroll-progress bar reads the --scroll-progress CSS var fed by Lenis.
* - Mobile: accessible disclosure menu (real <button aria-expanded>, Esc to
* close, focus returns to the toggle, body scroll locked while open).
*/
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Magnetic from "./Magnetic";
import { SITE } from "../content";
const NAV = [
{ href: "#services", label: "Services" },
{ href: "#work", label: "Work" },
{ href: "#about", label: "About" },
{ href: "#process", label: "About" },
{ href: "#faq", label: "FAQ" },
];
/** Masthead-style sticky header with a mono "ticker bug" logo. */
export default function SiteHeader() {
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const toggleRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
const onScroll = () => setScrolled(window.scrollY > 40);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// lock body + escape to close mobile nav
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
if (!open) return;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
toggleRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
window.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<header className={`masthead ${scrolled ? "is-scrolled" : ""}`}>
<div className="masthead__bar frame">
<a href="#main" className="logo" aria-label="Feedback Studios — home">
<span className="logo__bug" aria-hidden="true">
FS
<header className={`site-head ${scrolled ? "is-scrolled" : ""}`}>
<div className="progress-bar" aria-hidden="true" />
<div className="site-head__inner">
<Link href="#main" className="brand" aria-label={`${SITE.name} — home`}>
<span className="brand__mark" aria-hidden="true">
<span className="brand__dot" />
</span>
<span className="logo__name">Feedback&nbsp;Studios</span>
<span className="logo__tag" aria-hidden="true">
EST. 26 · REV
</span>
</a>
<span className="brand__name">Feedback&nbsp;Studios</span>
</Link>
<nav className="masthead__nav" aria-label="Primary">
<nav className="site-nav" aria-label="Primary">
<ul>
{NAV.map((n) => (
<li key={n.href}>
<a href={n.href}>{n.label}</a>
</li>
))}
</ul>
</nav>
<a
className="btn btn--accent masthead__cta"
href={SITE.booking}
data-cursor="Lets talk"
>
Get a growth audit
</a>
<button
className="masthead__burger"
aria-expanded={open}
aria-controls="mobile-nav"
onClick={() => setOpen((o) => !o)}
>
<span className="sr-only">{open ? "Close menu" : "Open menu"}</span>
<span className="masthead__burger-box" data-open={open} aria-hidden="true">
<i /><i /><i />
</span>
</button>
</div>
<div className="masthead__rule" aria-hidden="true" />
{/* mobile drawer */}
<div id="mobile-nav" className="drawer" data-open={open}>
<nav aria-label="Mobile">
<ul>
{NAV.map((n, i) => (
<li key={n.href} style={{ transitionDelay: `${i * 50 + 60}ms` }}>
<a href={n.href} onClick={() => setOpen(false)}>
<span className="drawer__idx">0{i + 1}</span>
<a href={n.href} data-cursor="">
{n.label}
</a>
</li>
))}
</ul>
</nav>
<a
className="btn btn--accent"
<div className="site-head__cta">
<Magnetic strength={0.5}>
<Link href={SITE.booking} className="btn btn--accent btn--sm" data-cursor="Let's talk">
Get a growth audit
</Link>
</Magnetic>
</div>
<button
ref={toggleRef}
className={`burger ${open ? "is-open" : ""}`}
aria-expanded={open}
aria-controls="mobile-menu"
aria-label={open ? "Close menu" : "Open menu"}
onClick={() => setOpen((v) => !v)}
>
<span /><span /><span />
</button>
</div>
<div id="mobile-menu" className={`mobile-menu ${open ? "is-open" : ""}`} hidden={!open}>
<nav aria-label="Mobile">
<ul>
{NAV.map((n) => (
<li key={n.href}>
<a href={n.href} onClick={() => setOpen(false)}>
{n.label}
</a>
</li>
))}
<li>
<Link
href={SITE.booking}
className="btn btn--accent"
onClick={() => setOpen(false)}
>
Get a growth audit
</a>
</Link>
</li>
</ul>
</nav>
</div>
</header>
);

View file

@ -1,20 +1,34 @@
"use client";
/**
* Lenis smooth scroll wired into GSAP ScrollTrigger.
* - Lenis drives the rAF loop; ScrollTrigger.update() runs on every Lenis tick
* so pinned/scrubbed animations stay in lockstep with the smoothed scroll.
* - Disabled entirely when prefers-reduced-motion is set (native scroll).
* - Exposes a scroll-progress value on :root for the top progress bar (CSS).
*/
import { useEffect } from "react";
import Lenis from "lenis";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { gsap, ScrollTrigger } from "./gsap";
/**
* Lenis smooth scroll wired into GSAP's ScrollTrigger so scroll-driven
* animation stays in sync. Disabled entirely under reduced-motion.
*/
export default function SmoothScroll() {
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
gsap.registerPlugin(ScrollTrigger);
// Always keep the CSS progress var fed (cheap), even without Lenis.
const setProgress = (p: number) =>
document.documentElement.style.setProperty("--scroll-progress", String(p));
if (reduce) {
const onScroll = () => {
const h = document.documentElement;
const max = h.scrollHeight - h.clientHeight;
setProgress(max > 0 ? h.scrollTop / max : 0);
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}
const lenis = new Lenis({
duration: 1.1,
@ -22,32 +36,18 @@ export default function SmoothScroll() {
smoothWheel: true,
});
lenis.on("scroll", ScrollTrigger.update);
lenis.on("scroll", (e: { progress: number }) => {
ScrollTrigger.update();
setProgress(e.progress);
});
const raf = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
// anchor links route through Lenis
const onClick = (e: MouseEvent) => {
const a = (e.target as HTMLElement)?.closest('a[href^="#"]') as
| HTMLAnchorElement
| null;
if (!a) return;
const id = a.getAttribute("href");
if (!id || id === "#") return;
const el = document.querySelector(id);
if (!el) return;
e.preventDefault();
lenis.scrollTo(el as HTMLElement, { offset: -80 });
};
document.addEventListener("click", onClick);
return () => {
document.removeEventListener("click", onClick);
gsap.ticker.remove(raf);
lenis.destroy();
ScrollTrigger.getAll().forEach((t) => t.kill());
};
}, []);

View file

@ -1,67 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
type Item = { v: string; up?: boolean; label: string };
/**
* A Bloomberg-style revenue ticker tape. Infinite CSS marquee with live-
* feeling deltas. The visible tape is aria-hidden (decorative motion); a
* single screen-reader summary conveys the same facts statically.
*
* Numbers are illustrative SAMPLES (matches the content note).
*/
const ITEMS: Item[] = [
{ v: "+$2.41M", up: true, label: "Q ARR added" },
{ v: "3.8×", up: true, label: "ROAS" },
{ v: "34%", up: true, label: "CPA" },
{ v: "+217%", up: true, label: "Demo reqs" },
{ v: "+183%", up: true, label: "Organic" },
{ v: "+128", up: true, label: "Bookings/mo" },
{ v: "+52%", up: true, label: "ROAS 90d" },
{ v: "92%", up: true, label: "Retention" },
{ v: "+$40M", up: true, label: "Client revenue" },
];
export default function Ticker() {
const [tick, setTick] = useState(0);
const reduce = useRef(false);
// gentle "last digit flicker" to feel live (skipped on reduced motion)
useEffect(() => {
reduce.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce.current) return;
const id = setInterval(() => setTick((t) => t + 1), 2600);
return () => clearInterval(id);
}, []);
const Row = ({ aria }: { aria: boolean }) => (
<ul className="ticker__row" aria-hidden={aria ? undefined : true}>
{ITEMS.map((it, i) => (
<li className="ticker__item" key={i}>
<span className="ticker__arr" aria-hidden="true">
{it.up ? "▲" : "▼"}
</span>
<span className="ticker__val">{it.v}</span>
<span className="ticker__lab">{it.label}</span>
</li>
))}
</ul>
);
return (
<div className="ticker" data-tick={tick % 2}>
{/* SR-only static summary of the same data */}
<p className="sr-only">
Sample results across recent client programs: 3.8 times average return
on ad spend, plus 217 percent qualified demo requests, plus 183 percent
organic traffic, 92 percent client retention, and over 40 million
dollars in client revenue generated.
</p>
<div className="ticker__track" aria-hidden="true">
<Row aria />
<Row aria />
</div>
</div>
);
}

View file

@ -0,0 +1,80 @@
"use client";
/**
* Infinite marquee whose speed + skew react to scroll velocity.
* - Base drift via Motion `animationFrame` + a wrapped motion value.
* - Scroll velocity (useVelocity over scrollY) adds directional boost + skew,
* so the tape "leans" as you fling the page a signature kinetic touch.
* - Two copies of the content guarantee a seamless loop.
* - prefers-reduced-motion: static row, no transform churn.
*/
import { useRef } from "react";
import {
motion,
useScroll,
useVelocity,
useSpring,
useTransform,
useMotionValue,
useAnimationFrame,
useReducedMotion,
wrap,
} from "motion/react";
export default function VelocityMarquee({
items,
baseVelocity = 60,
}: {
items: string[];
baseVelocity?: number;
}) {
const reduce = useReducedMotion();
const baseX = useMotionValue(0);
const { scrollY } = useScroll();
const scrollVelocity = useVelocity(scrollY);
const smoothVelocity = useSpring(scrollVelocity, { damping: 50, stiffness: 400 });
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 4], { clamp: false });
const skew = useTransform(smoothVelocity, [-2000, 0, 2000], [-6, 0, 6], { clamp: true });
// 50% because we render the content twice; wrap keeps it seamless.
const x = useTransform(baseX, (v) => `${wrap(-50, 0, v)}%`);
const directionFactor = useRef(1);
useAnimationFrame((_, delta) => {
if (reduce) return;
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
const vf = velocityFactor.get();
if (vf < 0) directionFactor.current = -1;
else if (vf > 0) directionFactor.current = 1;
moveBy += directionFactor.current * moveBy * vf;
baseX.set(baseX.get() + moveBy / 14);
});
const Row = (
<>
{items.map((it, i) => (
<span className="marq__item" key={i}>
{it}
<span className="marq__star" aria-hidden="true"></span>
</span>
))}
</>
);
if (reduce) {
return (
<div className="marq" aria-hidden="true">
<div className="marq__track marq__track--static">{Row}</div>
</div>
);
}
return (
<motion.div className="marq" style={{ skewX: skew }} aria-hidden="true">
<motion.div className="marq__track" style={{ x }}>
{Row}
{Row}
</motion.div>
</motion.div>
);
}

19
app/components/gsap.ts Normal file
View file

@ -0,0 +1,19 @@
/**
* Centralised GSAP setup. Registers the (now-free) premium plugins ONCE,
* client-side only. Import { gsap, ScrollTrigger, ... } from here so every
* component shares the same registration and we never double-register.
*
* GSAP 3.15 ships DrawSVG / MorphSVG / SplitText / ScrollTrigger in the
* package (April 2025: all club plugins went free).
*/
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
import { SplitText } from "gsap/SplitText";
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText);
}
export { gsap, ScrollTrigger, DrawSVGPlugin, MorphSVGPlugin, SplitText };

View file

@ -56,36 +56,40 @@ export const metrics = [
{ value: 92, suffix: "%", label: "client retention" },
] as const;
/**
* Case studies. Visuals are now CODED data charts (unique `bars` per case +
* `accent`), not the rejected repetitive raster images so no two look alike.
*/
export const cases = [
{
img: "/assets/case-1.png",
alt: "Glossy 3D capsule cluster in brand violet, blue and emerald, representing the e-commerce fashion case study",
tag: "E-commerce · Fashion",
problem: "Rising ad costs were eating the margin.",
result: "34% CPA and +52% ROAS in 90 days",
how: "Meta + Google Shopping restructure.",
metricNum: "+52%",
metricLabel: "ROAS in 90 days",
bars: [28, 40, 36, 58, 72, 92],
accent: "#8b5cf6",
},
{
img: "/assets/case-2.png",
alt: "Suspended glossy capsules in brand colors, representing the B2B SaaS case study",
tag: "B2B SaaS",
problem: "Plenty of traffic, no pipeline.",
result: "+217% qualified demo requests in 6 months",
how: "SEO + content + LinkedIn.",
metricNum: "+217%",
metricLabel: "demo requests",
bars: [18, 22, 30, 48, 70, 96],
accent: "#3b82f6",
},
{
img: "/assets/case-3.png",
alt: "Cascade of glossy capsules in brand colors, representing the aesthetic clinic case study",
tag: "Aesthetic clinic",
problem: "Empty calendar despite the ad spend.",
result: "+128 booked consultations a month",
how: "Paid + landing-page rebuild.",
metricNum: "+128",
metricLabel: "consultations / month",
bars: [24, 34, 30, 52, 66, 88],
accent: "#10b981",
},
] as const;

File diff suppressed because it is too large Load diff

View file

@ -102,7 +102,7 @@ export default function RootLayout({
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="theme-color" content="#f4f1ea" />
<meta name="theme-color" content="#07070b" />
</head>
<body>
<StructuredData />

View file

@ -1,24 +1,34 @@
import Link from "next/link";
import Image from "next/image";
import {
SITE,
services,
metrics,
cases,
processSteps,
testimonials,
} from "./content";
import SiteHeader from "./components/SiteHeader";
import RevenueField from "./components/RevenueField";
import KineticHeadline from "./components/KineticHeadline";
import Ticker from "./components/Ticker";
import CountUp from "./components/CountUp";
import Reveal from "./components/Reveal";
import BeforeAfter from "./components/BeforeAfter";
import Hero from "./components/Hero";
import VelocityMarquee from "./components/VelocityMarquee";
import Reveal, { RevealItem } from "./components/Reveal";
import Scoreboard from "./components/Scoreboard";
import CaseCard, { type CaseData } from "./components/CaseCard";
import ProcessLoop from "./components/ProcessLoop";
import Faq from "./components/Faq";
import Magnetic from "./components/Magnetic";
const PROOF = [
const TAPE = [
"Revenue, not vanity metrics",
"Paid",
"SEO",
"Content",
"Social",
"+52% ROAS",
"+217% demos",
"+128 booked/mo",
"Reported every month",
];
const INDUSTRIES = [
"E-commerce",
"B2B SaaS",
"Clinics",
@ -27,142 +37,58 @@ const PROOF = [
"Marketplaces",
];
const beforeAfter = [
{ before: "Flat sales", after: "+52% ROAS" },
{ before: "No pipeline", after: "+217% demos" },
{ before: "Empty calendar", after: "+128 booked/mo" },
];
export default function Page() {
return (
<>
<SiteHeader />
<main id="main">
{/* =========================================================
HERO the masthead front page
========================================================= */}
<section className="hero frame" aria-labelledby="hero-h1">
<div className="hero__field">
<RevenueField />
</div>
<Hero />
{/* dateline / edition strip */}
<div className="hero__dateline">
<span>The Revenue Edition</span>
<span aria-hidden="true">/</span>
<span>No. 01</span>
<span aria-hidden="true">/</span>
<span>Digital marketing agency</span>
</div>
<div className="rule rule--strong" />
{/* ===== KINETIC TAPE — scroll-velocity marquee ===== */}
<section className="tape" aria-label="What we do, at a glance">
<VelocityMarquee items={TAPE} />
</section>
<div className="hero__grid">
<div className="hero__lead">
<p className="kicker">
{/* ===== POSITIONING / MANIFESTO ===== */}
<section id="manifesto" className="manifesto frame" aria-labelledby="man-h">
<div className="wrap manifesto__wrap">
<Reveal as="p" className="kicker">
<span className="kicker__dot" />
Marketing, priced in revenue
</p>
<KineticHeadline className="display hero__h1">
<span id="hero-h1">
Marketing that grows your <span className="cash">revenue.</span>
The problem with most agencies
</Reveal>
<Reveal as="p" className="manifesto__lead display" delay={80}>
<span id="man-h">
Most budgets buy <span className="strike">activity</span>, not
outcomes. Dashboards fill with impressions while the sales number{" "}
<span className="grad">sits still.</span>
</span>
</KineticHeadline>
</div>
<div className="hero__side">
<p className="hero__sub">
We&apos;re a results-driven digital marketing agency. We run
paid, SEO, and content programs built around your revenue
targets, then show you what they returned. Every month.
</p>
<div className="hero__cta">
<Link
href={SITE.booking}
className="btn btn--accent"
data-cursor="Book it"
>
Get your growth audit
</Link>
<a href="#work" className="btn btn--ghost">
See the results <span className="arrow" aria-hidden="true"></span>
</a>
</div>
</div>
</div>
{/* hero stat slab */}
<div className="hero__slab">
<div className="hero__stat">
<span className="hero__stat-num display">$40M+</span>
<span className="hero__stat-lab">in client revenue generated</span>
</div>
<div className="hero__stat">
<span className="hero__stat-num display">50+</span>
<span className="hero__stat-lab">brands grown</span>
</div>
<div className="hero__stat hero__stat--live">
<span className="hero__live" aria-hidden="true" />
<span className="hero__stat-lab">
Live revenue feed sample data
</span>
</div>
</Reveal>
<Reveal as="p" className="manifesto__body" delay={180}>
We started Feedback Studios to fix that. Every campaign we run is
built to move revenue, and we report on it the way your CFO would.
No vanity metrics. No mystery. Just the number that pays the bills.
</Reveal>
</div>
</section>
{/* =========================================================
TICKER kinetic social proof tape
========================================================= */}
<section data-invert className="tape" aria-label="Sample results ticker">
<Ticker />
</section>
{/* =========================================================
SOCIAL PROOF / industries
========================================================= */}
<section className="proof frame" aria-label="Who we work with">
<div className="wrap proof__wrap">
{/* ===== INDUSTRIES / SOCIAL PROOF ===== */}
<section className="proof" aria-label="Who we work with">
<div className="wrap">
<p className="kicker proof__label">
Trusted by teams that care about the sales number
</p>
<ul className="proof__list">
{PROOF.map((p) => (
<li key={p} className="proof__item">
<Reveal as="ul" className="proof__list" stagger={0.06}>
{INDUSTRIES.map((p) => (
<RevealItem as="li" className="proof__item" key={p}>
{p}
</li>
</RevealItem>
))}
</ul>
</div>
</section>
{/* =========================================================
POSITIONING / PROBLEM the manifesto spread
========================================================= */}
<section data-invert className="manifesto frame" aria-labelledby="man-h">
<div className="wrap manifesto__wrap">
<p className="kicker">
<span className="kicker__dot" />
The problem with most agencies
</p>
<Reveal as="p" className="manifesto__lead display" >
<span id="man-h">
Most budgets buy <span className="strike">activity</span>, not
outcomes. Dashboards fill up with impressions while the sales
number <span className="cash">sits still.</span>
</span>
</Reveal>
<Reveal as="p" className="manifesto__body" delay={120}>
We started Feedback Studios to fix that. Every campaign we run is
built to move revenue, and we report on it the way your CFO
would. No vanity metrics. No mystery. Just the number that pays
the bills.
</Reveal>
</div>
</section>
{/* =========================================================
SERVICES the index / ledger
========================================================= */}
{/* ===== SERVICES — interactive ledger ===== */}
<section id="services" className="services frame" aria-labelledby="svc-h">
<div className="wrap">
<header className="sec-head">
@ -175,55 +101,27 @@ export default function Page() {
</h2>
</header>
<ol className="ledger">
<Reveal as="ol" className="ledger" stagger={0.07}>
{services.map((s, i) => (
<li key={s.id} className="ledger__row" data-cursor="">
<span className="ledger__no">{String(i + 1).padStart(2, "0")}</span>
<RevealItem as="li" key={s.id}>
<a href={SITE.booking} className="ledger__row" data-cursor="Scope it">
<span className="ledger__no">
{String(i + 1).padStart(2, "0")}
</span>
<h3 className="ledger__name display">{s.name}</h3>
<p className="ledger__desc">{s.desc}</p>
<span className="ledger__arrow" aria-hidden="true"></span>
</li>
</a>
</RevealItem>
))}
</ol>
</Reveal>
</div>
</section>
{/* =========================================================
METRICS count-up scoreboard (inverted)
========================================================= */}
<section data-invert className="score frame" aria-labelledby="score-h">
<div className="wrap">
<header className="sec-head sec-head--center">
<p className="kicker">
<span className="kicker__dot" />
The scoreboard sample data
</p>
<h2 id="score-h" className="display sec-head__title">
Numbers we report on
</h2>
</header>
<dl className="score__grid">
{metrics.map((m) => (
<div className="score__cell" key={m.label}>
<dt className="sr-only">{m.label}</dt>
<dd className={`score__num display ${"accent" in m && m.accent ? "is-accent" : ""}`}>
<CountUp
to={m.value}
prefix={"prefix" in m ? m.prefix : ""}
suffix={m.suffix}
decimals={"decimals" in m ? m.decimals : 0}
/>
</dd>
<p className="score__lab" aria-hidden="true">{m.label}</p>
</div>
))}
</dl>
</div>
</section>
{/* ===== METRICS scoreboard (animated SVG bars + count-ups) ===== */}
<Scoreboard />
{/* =========================================================
CASE STUDIES proof spreads w/ before/after sliders
========================================================= */}
{/* ===== CASE STUDIES — 3D tilt + coded charts ===== */}
<section id="work" className="work frame" aria-labelledby="work-h">
<div className="wrap">
<header className="sec-head">
@ -236,94 +134,35 @@ export default function Page() {
</h2>
</header>
<div className="work__list">
<div className="work__grid">
{cases.map((c, i) => (
<article className="case" key={c.tag}>
<div className="case__index">
<span className="case__no">{String(i + 1).padStart(2, "0")}</span>
<span className="case__tag">{c.tag}</span>
</div>
<div className="case__body">
<p className="case__problem">
<span className="case__problem-k">Before:</span> {c.problem}
</p>
<p className="case__result display">{c.result}</p>
<p className="case__how">{c.how}</p>
</div>
<div className="case__visual">
<BeforeAfter
before={beforeAfter[i].before}
after={beforeAfter[i].after}
caption={c.tag}
/>
{/* optional brand asset, lazy + sized to avoid CLS */}
<Image
src={c.img}
alt={c.alt}
width={1200}
height={896}
className="case__img"
loading="lazy"
sizes="(max-width: 900px) 90vw, 40vw"
/>
<div className="case__metric">
<span className="case__metric-num display">{c.metricNum}</span>
<span className="case__metric-lab">{c.metricLabel}</span>
</div>
</div>
</article>
<CaseCard key={c.tag} data={c as unknown as CaseData} index={i} />
))}
</div>
<div className="work__cta">
<a href={SITE.booking} className="btn btn--ghost">
<Magnetic strength={0.3}>
<a href={SITE.booking} className="btn btn--ghost" data-cursor="See more">
View all work <span className="arrow" aria-hidden="true"></span>
</a>
</Magnetic>
</div>
</div>
</section>
{/* =========================================================
PROCESS The Feedback Loop
========================================================= */}
<section data-invert id="about" className="loop frame" aria-labelledby="loop-h">
<div className="wrap">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
The Feedback Loop
</p>
<h2 id="loop-h" className="display sec-head__title">
How it works
</h2>
</header>
{/* ===== PROCESS — pinned scroll + morphing SVG ===== */}
<ProcessLoop />
<ol className="loop__grid">
{processSteps.map((p) => (
<li className="loop__step" key={p.n}>
<span className="loop__n display">{p.n}</span>
<h3 className="loop__name">{p.name}</h3>
<p className="loop__desc">{p.desc}</p>
</li>
))}
</ol>
</div>
</section>
{/* =========================================================
TESTIMONIALS pull quotes
========================================================= */}
{/* ===== TESTIMONIALS ===== */}
<section className="quotes frame" aria-label="What clients say">
<div className="wrap quotes__grid">
{testimonials.map((t, i) => (
<Reveal as="figure" className="quote" key={i} delay={i * 100}>
<blockquote className="quote__text display">
&ldquo;{t.quote}&rdquo;
</blockquote>
<Reveal as="figure" className="quote" key={i} delay={i * 120}>
<span className="quote__mark display" aria-hidden="true">&ldquo;</span>
<blockquote className="quote__text display">{t.quote}</blockquote>
<figcaption className="quote__by">
<span className="quote__rule" aria-hidden="true" /> {t.by}
<span className="quote__rule" aria-hidden="true" />
{t.by}
</figcaption>
</Reveal>
))}
@ -337,9 +176,7 @@ export default function Page() {
</div>
</section>
{/* =========================================================
FAQ
========================================================= */}
{/* ===== FAQ ===== */}
<section id="faq" data-invert className="faq-sec frame" aria-labelledby="faq-h">
<div className="wrap faq-sec__wrap">
<header className="sec-head">
@ -355,26 +192,28 @@ export default function Page() {
</div>
</section>
{/* =========================================================
FINAL CTA
========================================================= */}
{/* ===== FINAL CTA ===== */}
<section className="final frame" aria-labelledby="final-h">
<div className="wrap final__wrap">
<p className="kicker">
<span className="kicker__dot" />
The bottom line
</p>
<Reveal as="header">
<h2 id="final-h" className="display final__h">
Ready to <span className="cash">grow?</span>
Ready to <span className="grad">grow?</span>
</h2>
</Reveal>
<p className="final__sub">
No long contracts. No vanity reports. Marketing you can measure in
sales.
</p>
<div className="final__cta">
<Magnetic strength={0.4}>
<Link href={SITE.booking} className="btn btn--accent" data-cursor="Book a call">
Book a call
</Link>
</Magnetic>
<a href={`mailto:${SITE.email}`} className="btn btn--ghost">
or {SITE.email}
</a>
@ -383,14 +222,14 @@ export default function Page() {
</section>
</main>
{/* =========================================================
FOOTER the colophon
========================================================= */}
{/* ===== FOOTER ===== */}
<footer data-invert className="colophon frame" aria-label="Footer">
<div className="wrap">
<div className="colophon__top">
<div className="colophon__brand">
<span className="logo__bug" aria-hidden="true">FS</span>
<span className="brand__mark" aria-hidden="true">
<span className="brand__dot" />
</span>
<p className="colophon__line">
{SITE.name}. {SITE.tagline}
</p>
@ -399,7 +238,7 @@ export default function Page() {
<ul>
<li><a href="#services">Services</a></li>
<li><a href="#work">Work</a></li>
<li><a href="#about">About</a></li>
<li><a href="#process">About</a></li>
<li><a href="#faq">FAQ</a></li>
<li><a href={`mailto:${SITE.email}`}>Contact</a></li>
</ul>

69
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"gsap": "^3.12.5",
"lenis": "^1.1.14",
"motion": "^12.40.0",
"next": "^15.5.19",
"ogl": "^1.0.11",
"react": "19.0.0",
@ -718,6 +719,33 @@
"node": ">=8"
}
},
"node_modules/framer-motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.40.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/gsap": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
@ -755,6 +783,47 @@
}
}
},
"node_modules/motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz",
"integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.40.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",

View file

@ -13,6 +13,7 @@
"dependencies": {
"gsap": "^3.12.5",
"lenis": "^1.1.14",
"motion": "^12.40.0",
"next": "^15.5.19",
"ogl": "^1.0.11",
"react": "19.0.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View file

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 2 MiB

BIN
public/assets/hero.mp4 Normal file

Binary file not shown.