feat: craft pass (Emil design-eng) — easing system, momentum springs, press states, clip-path reveals, alive motion

This commit is contained in:
Feedback Studios 2026-06-16 07:37:48 +00:00
parent 80f46b9780
commit d5ad025607
14 changed files with 979 additions and 235 deletions

View file

@ -3,13 +3,17 @@
/**
* 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).
* - 3D tilt that tracks the pointer via a SOFT spring (momentum => the card
* floats and settles, never snaps). Transform-only.
* - A small lift + accent shadow on hover; a press dip (scale 0.985) so the
* whole card shares the page's tactile press language.
* - An animated bars + sparkline "result chart" (CSS + GSAP DrawSVG on the
* spark) growing into view no two cards look alike.
* - A glare/sheen that follows the cursor across the surface.
* Reduced-motion / touch: flat card, bars still grow on view via CSS.
* - The chart panel gets a clip-path inset() wipe on first view (premium).
* Reduced-motion / touch: flat card, bars still grow on view via CSS.
*/
import { useRef } from "react";
import { useEffect, useRef } from "react";
import {
motion,
useMotionValue,
@ -17,6 +21,8 @@ import {
useTransform,
useReducedMotion,
} from "motion/react";
import { gsap } from "./gsap";
import { SPRING, EASE_OUT } from "./motion";
export type CaseData = {
tag: string;
@ -29,17 +35,23 @@ export type CaseData = {
accent: string; // brand accent for this card
};
export default function CaseCard({ data, index }: { data: CaseData; index: number }) {
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 rx = useSpring(useTransform(my, [0, 1], [6, -6]), SPRING.tilt);
const ry = useSpring(useTransform(mx, [0, 1], [-8, 8]), SPRING.tilt);
const glare = useTransform(
[mx, my],
([gx, gy]: number[]) =>
`radial-gradient(circle at ${gx * 100}% ${gy * 100}%, rgba(255,255,255,0.14), transparent 45%)`
`radial-gradient(circle at ${gx * 100}% ${gy * 100}%, rgba(255,255,255,0.16), transparent 45%)`
);
const onMove = (e: React.PointerEvent) => {
@ -55,26 +67,57 @@ export default function CaseCard({ data, index }: { data: CaseData; index: numbe
my.set(0.5);
};
// DrawSVG the sparkline once the card scrolls in — a small, deliberate detail.
useEffect(() => {
const el = ref.current;
if (!el || reduce) return;
const ctx = gsap.context(() => {
gsap.from(el.querySelector(".case__spark polyline"), {
drawSVG: "0%",
duration: 1.1,
ease: "power2.out",
scrollTrigger: { trigger: el, start: "top 82%", once: true },
delay: 0.15 + index * 0.05,
});
}, el);
return () => ctx.revert();
}, [reduce, index]);
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 }}
style={
reduce
? undefined
: { rotateX: rx, rotateY: ry, transformPerspective: 1100 }
}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 44, filter: "blur(4px)" }}
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
whileTap={reduce ? undefined : { scale: 0.985 }}
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
transition={{ duration: 0.85, delay: index * 0.08, ease: EASE_OUT }}
>
<div className="case__inner" style={{ ["--case-accent" as string]: data.accent }}>
<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">
{/* coded data visual — unique bars per case, clip-wiped on first view */}
<motion.div
className="case__viz"
aria-hidden="true"
initial={reduce ? { opacity: 1 } : { clipPath: "inset(0 0 100% 0)" }}
whileInView={{ clipPath: "inset(0 0 0% 0)" }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 0.9, delay: index * 0.08 + 0.1, ease: EASE_OUT }}
>
<div className="case__bars">
{data.bars.map((h, i) => (
<span
@ -96,7 +139,7 @@ export default function CaseCard({ data, index }: { data: CaseData; index: numbe
strokeLinecap="round"
/>
</svg>
</div>
</motion.div>
<div className="case__body">
<p className="case__problem">

View file

@ -8,6 +8,7 @@
*/
import { useEffect, useRef, useState } from "react";
import { animate, useInView, useReducedMotion } from "motion/react";
import { EASE_OUT } from "./motion";
export default function CountUp({
to,
@ -35,7 +36,7 @@ export default function CountUp({
}
const controls = animate(0, to, {
duration,
ease: [0.16, 1, 0.3, 1],
ease: EASE_OUT,
onUpdate: (v) => setVal(v),
});
return () => controls.stop();

View file

@ -1,27 +1,32 @@
"use client";
/**
* 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).
* Custom cursor spring-following dot + trailing ring, the page's "alive" tell.
* - Ring trails with visible momentum (soft spring); dot leads (tighter spring)
* so the two have a believable lead/chase relationship never a rigid pair.
* - On [data-cursor] / links / buttons the ring grows + shows a contextual
* label.
* - On press (pointerdown anywhere) the ring dips to scale(0.82) for instant
* tactile feedback the same press language as the buttons.
* - Hidden on touch / coarse pointers and with prefers-reduced-motion
* (globals.css restores the native cursor).
*/
import { useEffect, useState } from "react";
import { motion, useMotionValue, useSpring } from "motion/react";
import { SPRING } from "./motion";
export default function Cursor() {
const [enabled, setEnabled] = useState(false);
const [hovering, setHovering] = useState(false);
const [label, setLabel] = useState("");
const [pressed, setPressed] = useState(false);
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 });
const ringX = useSpring(x, SPRING.cursorRing);
const ringY = useSpring(y, SPRING.cursorRing);
const dotX = useSpring(x, SPRING.cursorDot);
const dotY = useSpring(y, SPRING.cursorDot);
useEffect(() => {
const fine = window.matchMedia("(pointer: fine)").matches;
@ -44,10 +49,16 @@ export default function Cursor() {
setLabel("");
}
};
const down = () => setPressed(true);
const up = () => setPressed(false);
window.addEventListener("pointermove", move, { passive: true });
window.addEventListener("pointerdown", down, { passive: true });
window.addEventListener("pointerup", up, { passive: true });
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerdown", down);
window.removeEventListener("pointerup", up);
document.documentElement.classList.remove("has-custom-cursor");
};
}, [x, y]);
@ -57,12 +68,17 @@ export default function Cursor() {
return (
<div aria-hidden="true" className="cursor-layer">
<motion.div
className={`cursor-ring ${hovering ? "is-hover" : ""} ${label ? "is-labelled" : ""}`}
className={`cursor-ring ${hovering ? "is-hover" : ""} ${
label ? "is-labelled" : ""
} ${pressed ? "is-pressed" : ""}`}
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 }} />
<motion.div
className={`cursor-dot ${pressed ? "is-pressed" : ""}`}
style={{ x: dotX, y: dotY }}
/>
</div>
);
}

View file

@ -10,6 +10,7 @@
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { faqs } from "../content";
import { EASE_DRAWER, EASE_OUT } from "./motion";
export default function Faq() {
const [open, setOpen] = useState<number | null>(0);
@ -44,11 +45,25 @@ export default function Faq() {
aria-labelledby={`faq-btn-${i}`}
className="faq__panel"
initial={reduce ? { height: "auto", opacity: 1 } : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { height: "auto", opacity: 1 } : { height: 0, opacity: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
animate={{
height: "auto",
opacity: 1,
transition: { duration: 0.42, ease: EASE_DRAWER },
}}
exit={
reduce
? { height: "auto", opacity: 1 }
: { height: 0, opacity: 0, transition: { duration: 0.28, ease: EASE_OUT } }
}
>
<p className="faq__a">{f.a}</p>
<motion.p
className="faq__a"
initial={reduce ? false : { y: -6, opacity: 0 }}
animate={{ y: 0, opacity: 1, transition: { duration: 0.4, delay: 0.06, ease: EASE_OUT } }}
exit={reduce ? undefined : { opacity: 0, transition: { duration: 0.15 } }}
>
{f.a}
</motion.p>
</motion.div>
)}
</AnimatePresence>

View file

@ -1,14 +1,17 @@
"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.
* HERO the signature moment. One orchestrated, choreographed page-load with
* staggered reveals (the single highest-impact "alive" moment on the page).
* - Real background VIDEO (autoplay/muted/loop/playsinline, poster, cover,
* deferred load via IO, paused on hidden tab; reduced-motion -> poster only).
* - ANIMATED SVG: a revenue growth line that DRAWS itself (GSAP DrawSVG) with
* an area fill that fades up, grid ticks that draw in, and a "live" marker
* that springs in then pulses.
* - Headline revealed line-by-line behind a mask (clip + yPercent) with the
* custom expo curve; eyebrow/sub/CTAs/trust stagger up with a blur mask.
* - Pointer spotlight (spring-smoothed via CSS var) + parallax on scroll.
* - Custom easing personality throughout; honors prefers-reduced-motion.
*/
import { useEffect, useRef } from "react";
import Link from "next/link";
@ -16,17 +19,26 @@ import { gsap, SplitText } from "./gsap";
import Magnetic from "./Magnetic";
import { SITE } from "../content";
/* Custom GSAP eases that mirror the CSS tokens (registered once, idempotent). */
gsap.registerEase?.("emilOut", (p) => {
// approximates cubic-bezier(0.23,1,0.32,1) feel — strong, soft landing
return 1 - Math.pow(1 - p, 3.2);
});
export default function Hero() {
const root = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
// Spring-smoothed spotlight target so the glow trails the cursor with momentum
// instead of snapping to raw pointer position.
const target = useRef({ x: 50, y: 40 });
const current = useRef({ x: 50, y: 40 });
// 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}%`);
target.current.x = ((e.clientX - r.left) / r.width) * 100;
target.current.y = ((e.clientY - r.top) / r.height) * 100;
};
useEffect(() => {
@ -34,92 +46,138 @@ export default function Hero() {
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(() => {});
// --- spotlight: lerp current toward target each frame (momentum) ---
let rafId = 0;
if (!reduce) {
const tick = () => {
current.current.x += (target.current.x - current.current.x) * 0.08;
current.current.y += (target.current.y - current.current.y) * 0.08;
el.style.setProperty("--mx", `${current.current.x}%`);
el.style.setProperty("--my", `${current.current.y}%`);
rafId = requestAnimationFrame(tick);
};
document.addEventListener("visibilitychange", onVis);
rafId = requestAnimationFrame(tick);
}
const video = videoRef.current;
let io: IntersectionObserver | null = null;
let onVis: (() => void) | null = null;
if (!reduce) {
if (video) {
io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) video.play().catch(() => {});
else video.pause();
},
{ threshold: 0.1 }
);
io.observe(video);
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" });
// 1) Headline: split into lines+words, reveal each line from behind a
// mask (overflow hidden on lines) with a deliberate stagger.
const split = new SplitText(".hero__h1", {
type: "lines,words",
linesClass: "hero__line",
});
gsap.set(".hero__h1", { autoAlpha: 1 });
gsap.set(".hero__line", { overflow: "hidden" });
gsap.from(split.words, {
yPercent: 120,
opacity: 0,
yPercent: 118,
duration: 1.15,
ease: "emilOut",
stagger: 0.045,
delay: 0.2,
});
// 2) Eyebrow, sub, CTAs, trust — lift + blur mask, tight stagger.
gsap.set(".hero__stagger", { autoAlpha: 0 });
gsap.to(".hero__stagger", {
autoAlpha: 1,
y: 0,
filter: "blur(0px)",
duration: 1,
ease: "expo.out",
stagger: 0.04,
delay: 0.15,
ease: "emilOut",
stagger: 0.07,
delay: 0.55,
startAt: { y: 26, filter: "blur(5px)" },
});
// 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.
// 3) ANIMATED SVG — grid draws, the revenue line draws itself, area
// fades up, the live marker springs in (back ease => overshoot).
gsap.set(".hero-svg__area", { autoAlpha: 0 });
const tl = gsap.timeline({ delay: 0.4 });
const tl = gsap.timeline({ delay: 0.45 });
tl.from(".hero-svg__grid line", {
drawSVG: "0%",
duration: 0.8,
stagger: 0.04,
duration: 0.9,
stagger: 0.06,
ease: "power2.out",
})
.from(
".hero-svg__line",
{ drawSVG: "0%", duration: 1.8, ease: "power2.inOut" },
"-=0.4"
{ drawSVG: "0%", duration: 2, ease: "power2.inOut" },
"-=0.5"
)
.to(".hero-svg__area", { autoAlpha: 1, duration: 0.9 }, "-=1.1")
.to(".hero-svg__area", { autoAlpha: 1, duration: 1 }, "-=1.2")
.from(
".hero-svg__dot",
{ scale: 0, transformOrigin: "center", duration: 0.5, ease: "back.out(2)" },
{
scale: 0,
transformOrigin: "center",
duration: 0.6,
ease: "back.out(2.2)",
},
"-=0.5"
)
.from(
".hero-svg__dot-ring",
{ scale: 0.4, autoAlpha: 0, transformOrigin: "center", duration: 0.6, ease: "emilOut" },
"<"
);
// 4) Parallax: video drifts slower than content as you scroll away.
// 4) Parallax: video drifts slower; content + SVG drift at different
// rates as you scroll away => layered depth.
gsap.to(".hero__media", {
yPercent: 18,
yPercent: 16,
ease: "none",
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true },
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
});
gsap.to(".hero-svg", {
yPercent: 8,
ease: "none",
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
});
gsap.to(".hero__content", {
yPercent: -8,
opacity: 0.4,
yPercent: -10,
opacity: 0.3,
ease: "none",
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: true },
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
});
}, el);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
cancelAnimationFrame(rafId);
io?.disconnect();
if (onVis) 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 });
// Reduced motion: reveal everything statically, show poster only.
gsap.set([".hero__h1", ".hero__stagger", ".hero-svg__area"], {
autoAlpha: 1,
y: 0,
filter: "none",
});
return () => cancelAnimationFrame(rafId);
}, []);
return (
@ -144,6 +202,7 @@ export default function Hero() {
</video>
<div className="hero__scrim" />
<div className="hero__spotlight" />
<div className="hero__noise" />
</div>
{/* ---- animated SVG growth chart ---- */}
@ -169,13 +228,11 @@ export default function Hero() {
<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"
@ -184,6 +241,7 @@ export default function Hero() {
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-ring" cx="1200" cy="86" r="18" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0.5" />
<circle className="hero-svg__dot" cx="1200" cy="86" r="9" fill="#10b981" />
</svg>
@ -205,7 +263,11 @@ export default function Hero() {
<div className="hero__cta hero__stagger">
<Magnetic strength={0.4}>
<Link href={SITE.booking} className="btn btn--accent" data-cursor="Book it">
<Link
href={SITE.booking}
className="btn btn--accent"
data-cursor="Book it"
>
Get your growth audit
</Link>
</Magnetic>
@ -229,7 +291,11 @@ export default function Hero() {
</dl>
</div>
<a href="#manifesto" className="hero__scroll hero__stagger" aria-label="Scroll to content">
<a
href="#manifesto"
className="hero__scroll hero__stagger"
aria-label="Scroll to content"
>
<span className="hero__scroll-line" aria-hidden="true" />
Scroll
</a>

View file

@ -1,17 +1,20 @@
"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.
* Magnetic wrapper the child is pulled toward the cursor while hovered, then
* springs back with momentum on leave (loose spring => visible overshoot, the
* "alive" feel). The label inside also drifts at a deeper strength for a subtle
* parallax between the button and its text.
* 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";
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";
import { SPRING } from "./motion";
export default function Magnetic({
children,
strength = 0.4,
strength = 0.35,
className,
}: {
children: React.ReactNode;
@ -21,8 +24,11 @@ export default function Magnetic({
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 sx = useSpring(x, SPRING.magnetic);
const sy = useSpring(y, SPRING.magnetic);
// Inner content drifts a touch further => parallax depth between shell + label.
const innerX = useTransform(sx, (v) => v * 0.35);
const innerY = useTransform(sy, (v) => v * 0.35);
const onMove = (e: React.PointerEvent) => {
if (e.pointerType !== "mouse") return;
@ -47,7 +53,9 @@ export default function Magnetic({
onPointerLeave={reset}
style={{ x: sx, y: sy, display: "inline-block" }}
>
{children}
<motion.span style={{ x: innerX, y: innerY, display: "inline-block" }}>
{children}
</motion.span>
</motion.span>
);
}

View file

@ -62,18 +62,33 @@ export default function ProcessLoop() {
});
steps[0]?.classList.add("is-active");
const counter = el.querySelector<HTMLElement>(".loop__count-cur");
const node = el.querySelector<HTMLElement>(".loop-rail__node");
SHAPES.forEach((shape, i) => {
if (i === 0) return; // start shape already in markup
const at = i - 1;
// morph the glyph + a counter-rotation so it tumbles as it transforms,
// and grow the rail fill (+ a node that rides the fill edge).
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-glyph", { rotate: i * 5, duration: 1, ease: "power2.inOut" }, at)
.to(
".loop-rail__fill",
{ scaleY: i / (total - 1), duration: 1, ease: "none" },
at
)
.to(
node,
{ top: `${(i / (total - 1)) * 100}%`, duration: 1, ease: "none" },
at
);
});
// separate light update so the counter is robust at every scroll position
tl.eventCallback("onUpdate", () => {
const idx = Math.min(total, Math.floor(tl.progress() * total) + 1);
if (counter) counter.textContent = String(idx).padStart(2, "0");
});
}, el);
return () => ctx.revert();
@ -83,14 +98,21 @@ export default function ProcessLoop() {
<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
<header className="sec-head loop__head">
<div>
<p className="kicker">
<span className="kicker__dot" />
The Feedback Loop
</p>
<h2 id="loop-h" className="display sec-head__title">
How it works
</h2>
</div>
<p className="loop__count" aria-hidden="true">
<span className="loop__count-cur">01</span>
<span className="loop__count-sep">/</span>
<span className="loop__count-tot">0{processSteps.length}</span>
</p>
<h2 id="loop-h" className="display sec-head__title">
How it works
</h2>
</header>
<div className="loop__stage">
@ -112,6 +134,7 @@ export default function ProcessLoop() {
<div className="loop__steps">
<div className="loop-rail" aria-hidden="true">
<span className="loop-rail__fill" />
<span className="loop-rail__node" />
</div>
<ol>
{processSteps.map((p) => (

View file

@ -1,13 +1,18 @@
"use client";
/**
* 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).
* In-view reveal the page's core entrance vocabulary.
* - Default: lift + fade + a touch of blur(4px -> 0) so content resolves INTO
* place instead of just sliding (Emil's blur-masked entrance). Never appears
* from nothing.
* - variant="clip": a premium clip-path inset() wipe (bottom -> full) for
* section/media reveals, paired with a small lift.
* - `stagger` cascades direct <RevealItem> children via variants (3080ms).
* - Custom EASE_OUT curve everywhere; honors prefers-reduced-motion (keeps
* opacity/color, drops all movement + blur).
*/
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "motion/react";
import { motion, type Variants, useReducedMotion } from "motion/react";
import { EASE_OUT } from "./motion";
type Props = {
children: React.ReactNode;
@ -15,15 +20,29 @@ type Props = {
delay?: number;
y?: number;
stagger?: number;
as?: "div" | "section" | "ul" | "ol" | "li" | "p" | "figure" | "header";
variant?: "lift" | "clip";
as?:
| "div"
| "section"
| "ul"
| "ol"
| "li"
| "p"
| "figure"
| "header"
| "span"
| "h2";
};
const VIEWPORT = { once: true, margin: "0px 0px -12% 0px" } as const;
export default function Reveal({
children,
className,
delay = 0,
y = 26,
y = 28,
stagger,
variant = "lift",
as = "div",
}: Props) {
const reduce = useReducedMotion();
@ -32,7 +51,12 @@ export default function Reveal({
if (stagger) {
const parent: Variants = {
hidden: {},
show: { transition: { staggerChildren: reduce ? 0 : stagger, delayChildren: delay } },
show: {
transition: {
staggerChildren: reduce ? 0 : stagger,
delayChildren: delay / 1000,
},
},
};
return (
<MotionTag
@ -40,20 +64,34 @@ export default function Reveal({
variants={parent}
initial="hidden"
whileInView="show"
viewport={{ once: true, margin: "0px 0px -12% 0px" }}
viewport={VIEWPORT}
>
{children}
</MotionTag>
);
}
const hidden =
variant === "clip"
? reduce
? { opacity: 1 }
: { opacity: 0, y: y * 0.5, clipPath: "inset(0 0 100% 0)" }
: reduce
? { opacity: 1 }
: { opacity: 0, y, filter: "blur(4px)" };
const shown =
variant === "clip"
? { opacity: 1, y: 0, clipPath: "inset(0 0 0% 0)" }
: { opacity: 1, y: 0, filter: "blur(0px)" };
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] }}
initial={hidden}
whileInView={shown}
viewport={VIEWPORT}
transition={{ duration: 0.9, delay: delay / 1000, ease: EASE_OUT }}
>
{children}
</MotionTag>
@ -64,7 +102,7 @@ export default function Reveal({
export function RevealItem({
children,
className,
y = 22,
y = 24,
as = "div",
}: {
children: React.ReactNode;
@ -75,8 +113,13 @@ export function RevealItem({
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] } },
hidden: reduce ? { opacity: 1 } : { opacity: 0, y, filter: "blur(4px)" },
show: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.75, ease: EASE_OUT },
},
};
return (
<MotionTag className={className} variants={item}>

View file

@ -25,19 +25,38 @@ export default function Scoreboard() {
const ctx = gsap.context(() => {
const tl = gsap.timeline({
scrollTrigger: { trigger: ".score__chart", start: "top 80%", once: true },
scrollTrigger: { trigger: ".score__chart", start: "top 82%", 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"
);
// Baseline draws first, then bars grow up off it with a tight stagger and
// a soft landing (back ease => a hair of overshoot so they feel physical).
tl.from(".score__baseline", {
drawSVG: "0%",
duration: 0.8,
ease: "power2.out",
})
.from(
".score-bar",
{
scaleY: 0,
transformOrigin: "bottom",
duration: 1,
ease: "back.out(1.4)",
stagger: 0.07,
},
"-=0.45"
)
.from(
".score-bar__cap",
{
scale: 0,
autoAlpha: 0,
transformOrigin: "center",
duration: 0.5,
ease: "back.out(2.4)",
stagger: 0.07,
},
"-=0.9"
);
}, el);
return () => ctx.revert();
@ -81,7 +100,9 @@ export default function Scoreboard() {
<div className="score__chart" aria-hidden="true">
<div className="score__bars">
{BARS.map((h, i) => (
<span key={i} className="score-bar" style={{ height: `${h}%` }} />
<span key={i} className="score-bar" style={{ height: `${h}%` }}>
<span className="score-bar__cap" />
</span>
))}
</div>
<svg className="score__axis" viewBox="0 0 600 8" preserveAspectRatio="none">

View file

@ -0,0 +1,74 @@
"use client";
/**
* Cinematic section divider a thin gradient rule that DRAWS itself across the
* viewport (GSAP DrawSVG) as it scrolls into view, with a travelling node that
* rides along it. Pure decoration; adds rhythm + "life" between sections so the
* page never reads as empty stacked blocks.
* Reduced-motion: renders fully drawn, no travel.
*/
import { useEffect, useRef } from "react";
import { gsap } from "./gsap";
export default function SectionDivider({ label }: { label?: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.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: el, start: "top 88%", once: true },
});
tl.from(".divider__line", {
drawSVG: "50% 50%",
duration: 1.2,
ease: "power2.inOut",
})
.from(
".divider__node",
{ scale: 0, transformOrigin: "center", duration: 0.5, ease: "back.out(2.2)" },
"-=0.4"
)
.from(
".divider__label",
{ autoAlpha: 0, y: 8, filter: "blur(4px)", duration: 0.6, ease: "power2.out" },
"-=0.4"
);
// node drifts gently along the line on scroll => parallax life
gsap.to(".divider__node", {
x: 120,
ease: "none",
scrollTrigger: { trigger: el, start: "top bottom", end: "bottom top", scrub: 1 },
});
}, el);
return () => ctx.revert();
}, []);
return (
<div ref={ref} className="divider wrap" aria-hidden="true">
{label && <span className="divider__label">{label}</span>}
<svg className="divider__svg" viewBox="0 0 1000 12" preserveAspectRatio="none">
<defs>
<linearGradient id="dividerGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.1" />
<stop offset="50%" stopColor="#8b5cf6" stopOpacity="0.9" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.1" />
</linearGradient>
</defs>
<line
className="divider__line"
x1="0" y1="6" x2="1000" y2="6"
stroke="url(#dividerGrad)"
strokeWidth="1.5"
/>
</svg>
<span className="divider__node" />
</div>
);
}

View file

@ -22,6 +22,7 @@ const NAV = [
export default function SiteHeader() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const [active, setActive] = useState<string>("");
const toggleRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
@ -31,6 +32,26 @@ export default function SiteHeader() {
return () => window.removeEventListener("scroll", onScroll);
}, []);
// Scroll-spy: highlight the nav item for the section currently in view, so
// location is always legible (navigation best practice + a touch of life).
useEffect(() => {
const ids = NAV.map((n) => n.href.slice(1));
const sections = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => !!el);
if (!sections.length) return;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) setActive(`#${e.target.id}`);
});
},
{ rootMargin: "-45% 0px -50% 0px", threshold: 0 }
);
sections.forEach((s) => io.observe(s));
return () => io.disconnect();
}, []);
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
@ -60,13 +81,20 @@ export default function SiteHeader() {
<nav className="site-nav" aria-label="Primary">
<ul>
{NAV.map((n) => (
<li key={n.href}>
<a href={n.href} data-cursor="">
{n.label}
</a>
</li>
))}
{NAV.map((n) => {
const isActive = active === n.href;
return (
<li key={n.href}>
<a
href={n.href}
className={isActive ? "is-active" : ""}
aria-current={isActive ? "true" : undefined}
>
{n.label}
</a>
</li>
);
})}
</ul>
</nav>

38
app/components/motion.ts Normal file
View file

@ -0,0 +1,38 @@
/**
* Shared motion personality Emil Kowalski craft methodology.
*
* One cohesive easing/spring vocabulary used across the whole page so every
* interaction feels like it belongs to the same product. Springs (not raw
* mouse-tracking) drive ALL decorative/pointer motion so it has momentum and
* feels alive; bezier curves drive everything choreographed.
*/
import type { Transition } from "motion/react";
/* Custom bezier curves (mirror globals.css tokens). */
export const EASE_OUT = [0.23, 1, 0.32, 1] as const; // entrances / settle
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const; // on-screen movement
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; // panels / accordions
/* Springs — soft & momentum-rich so motion has life, never mechanical. */
export const SPRING = {
/* magnetic buttons / pointer pull — loose, lively, slight overshoot */
magnetic: { stiffness: 150, damping: 13, mass: 0.7 } satisfies Transition,
/* 3D card tilt — heavier so it floats and settles, never snaps */
tilt: { stiffness: 110, damping: 14, mass: 0.9 } satisfies Transition,
/* cursor ring — trails the pointer with visible lag (the "alive" feel) */
cursorRing: { stiffness: 220, damping: 24, mass: 0.7 } satisfies Transition,
/* cursor dot — tighter so it leads, ring chases it */
cursorDot: { stiffness: 600, damping: 34, mass: 0.5 } satisfies Transition,
/* generic decorative drift */
soft: { stiffness: 120, damping: 18, mass: 0.8 } satisfies Transition,
} as const;
/* Asymmetric enter/exit: deliberate in (ease-out), snappy out. */
export const ENTER = (duration = 0.9): Transition => ({
duration,
ease: EASE_OUT,
});
export const EXIT = (duration = 0.22): Transition => ({
duration,
ease: EASE_OUT,
});

View file

@ -39,6 +39,8 @@
/* gradients */
--grad-brand: linear-gradient(100deg, var(--blue), var(--violet) 52%, var(--emerald));
--grad-text: linear-gradient(100deg, #7cb2ff, #b69bff 50%, #4ee3ad);
/* darker brand gradient for legible gradient-text on the light "paper" bg */
--grad-text-ink: linear-gradient(100deg, #2563eb, #7c3aed 50%, #059669);
/* inverted (light "paper") sections */
--paper: #f4f1ea;
@ -66,9 +68,21 @@
--section-y: clamp(4.5rem, 9vw, 9rem);
--radius: 18px;
/* motion */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--t: 0.3s var(--ease);
/* motion Emil Kowalski craft easing system.
Built-in CSS easings are too weak; these are the agency's signature curves.
NEVER use ease-in for UI. */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* entrances, on-screen settle */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* movement that starts AND stops on screen */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* panels, drawers, accordions */
--ease: var(--ease-out); /* legacy alias */
/* timing personality — UI interactions stay <300ms */
--t-press: 0.14s; /* button/link press feedback */
--t-fast: 0.18s; /* hover micro-motion */
--t-mid: 0.26s; /* dropdowns, small reveals */
--t-slow: 0.5s; /* larger settle */
--t: var(--t-mid) var(--ease-out);
--t-move: var(--t-mid) var(--ease-in-out);
--scroll-progress: 0;
color-scheme: dark;
@ -103,10 +117,14 @@ a { color: inherit; text-decoration: none; }
ul, ol { list-style: none; padding: 0; }
button { font: inherit; color: inherit; cursor: pointer; background: none; border: none; }
:focus-visible {
outline: 3px solid var(--color-accent);
outline: 3px solid #6ee7b7; /* emerald-300: ~6:1 on the dark canvas */
outline-offset: 3px;
border-radius: 4px;
}
/* On light "paper" sections use a dark violet so the ring stays >=3:1 */
[data-invert] :focus-visible {
outline-color: var(--violet-600);
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
@ -223,6 +241,8 @@ body::before {
}
[data-invert] .kicker { color: var(--paper-dim); }
[data-invert] .rule { background: var(--paper-line); }
/* gradient text must stay legible on the light paper bg */
[data-invert] .grad { background-image: var(--grad-text-ink); }
/* ---------------------------------------------------------------------------
5. BUTTONS
@ -238,35 +258,66 @@ body::before {
font-size: var(--step-0);
letter-spacing: -0.01em;
border: 1.5px solid transparent;
transition: transform var(--t), box-shadow var(--t), background var(--t),
border-color var(--t), color var(--t);
transition:
transform var(--t-press) var(--ease-out),
box-shadow var(--t-mid) var(--ease-out),
background-position 0.55s var(--ease-out),
border-color var(--t-mid) var(--ease-out),
color var(--t-mid) var(--ease-out);
will-change: transform;
overflow: hidden;
}
.btn--sm { padding: 0.6rem 1.1rem; font-size: var(--step--1); }
.btn--accent {
color: #fff;
background: var(--grad-brand);
background-size: 160% 160%;
background-size: 200% 200%;
background-position: 0% 50%;
box-shadow: 0 8px 30px -8px rgba(139, 92, 246, 0.6);
box-shadow: 0 8px 30px -8px rgba(139, 92, 246, 0.55);
}
.btn--accent:hover {
background-position: 100% 50%;
box-shadow: 0 14px 40px -8px rgba(139, 92, 246, 0.7);
/* sheen that sweeps across on hover — a small, deliberate detail */
.btn--accent::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
105deg,
transparent 30%,
rgba(255, 255, 255, 0.28) 48%,
transparent 66%
);
transform: translateX(-120%);
transition: transform 0.7s var(--ease-out);
pointer-events: none;
}
.btn--accent:active { transform: scale(0.97); }
@media (hover: hover) and (pointer: fine) {
.btn--accent:hover {
background-position: 100% 50%;
box-shadow: 0 16px 44px -8px rgba(139, 92, 246, 0.7);
transform: translateY(-1px);
}
.btn--accent:hover::after { transform: translateX(120%); }
}
/* press: instant tactile dip, faster than the hover settle */
.btn--accent:active { transform: scale(0.97); transition-duration: var(--t-press); }
.btn--ghost {
color: var(--c-text);
border-color: var(--c-line-strong);
background: rgba(255, 255, 255, 0.02);
}
.btn--ghost:hover {
border-color: var(--violet);
background: rgba(139, 92, 246, 0.1);
@media (hover: hover) and (pointer: fine) {
.btn--ghost:hover {
border-color: var(--violet);
background: rgba(139, 92, 246, 0.1);
transform: translateY(-1px);
}
.btn--ghost:hover .arrow { transform: translateX(5px); }
}
.btn--ghost:hover .arrow { transform: translateX(4px); }
.btn--ghost:active { transform: scale(0.97); transition-duration: var(--t-press); }
[data-invert] .btn--ghost { color: var(--paper-ink); border-color: var(--paper-line); }
[data-invert] .btn--ghost:hover { border-color: var(--violet-600); background: rgba(124, 58, 237, 0.08); }
@media (hover: hover) and (pointer: fine) {
[data-invert] .btn--ghost:hover { border-color: var(--violet-600); background: rgba(124, 58, 237, 0.08); }
}
/* ---------------------------------------------------------------------------
6. CUSTOM CURSOR
@ -293,14 +344,21 @@ body::before {
margin: -3.5px 0 0 -3.5px;
background: var(--emerald);
mix-blend-mode: difference;
transition: transform var(--t-press) var(--ease-out), opacity var(--t-fast) var(--ease-out);
}
.cursor-dot.is-pressed { transform: scale(0.5); }
.cursor-ring {
width: 38px; height: 38px;
margin: -19px 0 0 -19px;
border: 1.5px solid rgba(255, 255, 255, 0.55);
display: grid; place-items: center;
transition: width 0.25s var(--ease), height 0.25s var(--ease),
background 0.25s var(--ease), border-color 0.25s var(--ease);
transition:
width var(--t-mid) var(--ease-out),
height var(--t-mid) var(--ease-out),
margin var(--t-mid) var(--ease-out),
transform var(--t-press) var(--ease-out),
background var(--t-mid) var(--ease-out),
border-color var(--t-mid) var(--ease-out);
}
.cursor-ring.is-hover {
width: 56px; height: 56px;
@ -314,6 +372,8 @@ body::before {
background: var(--violet);
border-color: var(--violet);
}
/* press: the ring dips, sharing the buttons' tactile language */
.cursor-ring.is-pressed { transform: scale(0.82); }
.cursor-ring__label {
font-family: var(--font-mono);
font-size: 11px;
@ -368,8 +428,24 @@ body::before {
width: 30px; height: 30px;
border-radius: 9px;
background: var(--grad-brand);
transition: transform var(--t-mid) var(--ease-out), box-shadow var(--t-mid) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.brand:hover .brand__mark {
transform: rotate(-8deg) scale(1.06);
box-shadow: 0 6px 20px -6px rgba(139, 92, 246, 0.7);
}
}
.brand:active .brand__mark { transform: scale(0.94); transition-duration: var(--t-press); }
.brand__dot {
width: 9px; height: 9px;
border-radius: 3px;
background: #fff;
transition: transform var(--t-mid) var(--ease-drawer);
}
@media (hover: hover) and (pointer: fine) {
.brand:hover .brand__dot { transform: rotate(45deg); border-radius: 2px; }
}
.brand__dot { width: 9px; height: 9px; border-radius: 3px; background: #fff; }
.brand__name { font-size: var(--step-0); }
.site-nav { margin-left: auto; }
.site-nav ul {
@ -381,21 +457,26 @@ body::before {
.site-nav a {
position: relative;
color: var(--c-text-dim);
transition: color var(--t);
transition: color var(--t-mid) var(--ease-out);
padding-block: 0.3rem;
}
.site-nav a::after {
content: "";
position: absolute;
left: 0; bottom: 0;
height: 1.5px; width: 0;
height: 1.5px; width: 100%;
background: var(--violet);
transition: width var(--t);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--t-mid) var(--ease-out);
}
.site-nav a:hover,
.site-nav a:focus-visible { color: var(--c-text); }
.site-nav a:hover::after,
.site-nav a:focus-visible::after { width: 100%; }
.site-nav a:focus-visible::after { transform: scaleX(1); }
/* scroll-spy active state */
.site-nav a.is-active { color: var(--c-text); }
.site-nav a.is-active::after { transform: scaleX(1); background: var(--grad-brand); }
.burger {
display: none;
width: 44px; height: 44px;
@ -407,10 +488,11 @@ body::before {
.burger span {
width: 22px; height: 2px;
background: var(--c-text);
transition: transform var(--t), opacity var(--t);
transition: transform var(--t-mid) var(--ease-drawer), opacity var(--t-fast) var(--ease-out);
}
.burger:active { transform: scale(0.92); }
.burger.is-open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.burger.is-open span:nth-child(2) { opacity: 0; }
.burger.is-open span:nth-child(2) { opacity: 0; transform: scaleX(0.4); }
.burger.is-open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.mobile-menu { display: none; }
@media (max-width: 860px) {
@ -452,20 +534,28 @@ body::before {
--mx: 50%;
--my: 40%;
}
.hero__media { position: absolute; inset: -10% 0; z-index: 0; }
.hero__media { position: absolute; inset: -12% 0; z-index: 0; will-change: transform; }
.hero__video { width: 100%; height: 100%; object-fit: cover; opacity: 0.55; }
.hero__scrim {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(7, 7, 11, 0.55), rgba(7, 7, 11, 0.82) 60%, var(--c-bg)),
linear-gradient(90deg, rgba(7, 7, 11, 0.7), transparent 60%);
linear-gradient(180deg, rgba(7, 7, 11, 0.5), rgba(7, 7, 11, 0.82) 60%, var(--c-bg)),
linear-gradient(90deg, rgba(7, 7, 11, 0.72), transparent 62%);
}
.hero__spotlight {
position: absolute;
inset: 0;
background: radial-gradient(420px circle at var(--mx) var(--my), rgba(139, 92, 246, 0.22), transparent 60%);
transition: background 0.15s linear;
background: radial-gradient(440px circle at var(--mx) var(--my), rgba(139, 92, 246, 0.24), transparent 60%);
mix-blend-mode: screen;
}
/* fine grain over the video so it never looks like flat stock footage */
.hero__noise {
position: absolute;
inset: 0;
opacity: 0.06;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.hero-svg {
position: absolute;
@ -479,12 +569,22 @@ body::before {
.hero-svg__line { filter: drop-shadow(0 0 14px rgba(139, 92, 246, 0.5)); }
.hero-svg__dot {
filter: drop-shadow(0 0 12px rgba(16, 185, 129, 0.9));
animation: heroPulse 2.4s var(--ease) infinite;
animation: heroPulse 2.4s var(--ease-in-out) infinite;
}
/* expanding ring around the live marker — radar/ping feel */
.hero-svg__dot-ring {
transform-box: fill-box;
transform-origin: center;
animation: heroPing 2.8s var(--ease-out) infinite;
}
@keyframes heroPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes heroPing {
0% { transform: scale(0.6); opacity: 0.7; }
70%, 100% { transform: scale(2.1); opacity: 0; }
}
.hero__content {
position: relative;
z-index: 2;
@ -522,6 +622,15 @@ body::before {
max-width: 15ch;
visibility: hidden; /* revealed by GSAP; reduced-motion fallback re-shows */
}
/* SplitText line wrappers — clip so words rise from behind a mask */
.hero__line { overflow: hidden; padding-bottom: 0.06em; }
.hero__h1 .word { display: inline-block; }
/* FOUC guard: hide stagger items until GSAP takes over (re-shown by JS, or by
the reduced-motion fallback below). */
.hero__stagger { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
.hero__stagger { opacity: 1; }
}
.hero__sub {
margin-top: 1.6rem;
max-width: 46ch;
@ -602,7 +711,18 @@ body::before {
letter-spacing: -0.02em;
color: var(--c-text);
}
.marq__star { color: var(--violet); font-size: 0.6em; }
/* every other item is outlined => richer, layered tape (not flat text) */
.marq__item:nth-child(even) {
color: transparent;
-webkit-text-stroke: 1px var(--c-text-faint);
}
.marq__star {
color: var(--violet);
font-size: 0.6em;
display: inline-block;
animation: starSpin 9s linear infinite;
}
@keyframes starSpin { to { transform: rotate(360deg); } }
/* ---------------------------------------------------------------------------
10. MANIFESTO
@ -618,6 +738,76 @@ body::before {
color: var(--c-text-dim);
max-width: 52ch;
}
.manifesto__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(1rem, 3vw, 2.5rem);
margin-top: clamp(2.5rem, 5vw, 4rem);
padding-top: clamp(1.6rem, 3vw, 2.4rem);
border-top: 1px solid var(--c-line);
}
.manifesto__stat {
display: flex;
flex-direction: column;
gap: 0.4rem;
position: relative;
padding-left: 1rem;
}
.manifesto__stat::before {
content: "";
position: absolute;
left: 0; top: 0.2rem; bottom: 0.2rem;
width: 2px;
background: var(--grad-brand);
border-radius: 2px;
transform: scaleY(0.6);
transform-origin: top;
transition: transform var(--t-mid) var(--ease-out);
}
.manifesto__stat:hover::before { transform: scaleY(1); }
.manifesto__stat-v { font-size: var(--step-2); letter-spacing: -0.02em; }
.manifesto__stat-k {
font-family: var(--font-mono);
font-size: var(--step--1);
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--c-text-faint);
}
@media (max-width: 640px) {
.manifesto__stats { grid-template-columns: 1fr; gap: 1.4rem; }
}
/* ---------------------------------------------------------------------------
11b. SECTION DIVIDER (cinematic drawn rule)
--------------------------------------------------------------------------- */
.divider {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 1.2rem;
padding-block: clamp(1.2rem, 3vw, 2.2rem);
}
.divider__label {
font-family: var(--font-mono);
font-size: var(--step--1);
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--c-text-faint);
white-space: nowrap;
flex-shrink: 0;
}
.divider__svg { flex: 1; height: 12px; }
.divider__line { will-change: stroke-dashoffset; }
.divider__node {
flex-shrink: 0;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--emerald);
box-shadow: 0 0 12px rgba(16, 185, 129, 0.8);
margin-left: -1.2rem;
will-change: transform;
}
/* ---------------------------------------------------------------------------
11. PROOF / INDUSTRIES
@ -631,19 +821,38 @@ body::before {
.proof__label { display: block; margin-bottom: 1.6rem; }
.proof__list { display: flex; flex-wrap: wrap; gap: 0.7rem 0.9rem; }
.proof__item {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 1.1rem;
border: 1px solid var(--c-line-strong);
border-radius: 999px;
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--c-text-dim);
transition: color var(--t), border-color var(--t), background var(--t), transform var(--t);
transition:
color var(--t-mid) var(--ease-out),
border-color var(--t-mid) var(--ease-out),
background var(--t-mid) var(--ease-out),
transform var(--t-fast) var(--ease-out);
}
.proof__item:hover {
color: var(--c-text);
border-color: var(--violet);
background: rgba(139, 92, 246, 0.08);
transform: translateY(-2px);
.proof__item-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--c-text-faint);
transition: background var(--t-mid) var(--ease-out), box-shadow var(--t-mid) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.proof__item:hover {
color: var(--c-text);
border-color: var(--violet);
background: rgba(139, 92, 246, 0.08);
transform: translateY(-3px);
}
.proof__item:hover .proof__item-dot {
background: var(--emerald);
box-shadow: 0 0 8px var(--emerald);
}
}
/* ---------------------------------------------------------------------------
@ -658,7 +867,7 @@ body::before {
padding-block: clamp(1.4rem, 3vw, 2.2rem);
border-bottom: 1px solid var(--c-line);
position: relative;
transition: padding-left var(--t);
transition: padding-left var(--t-mid) var(--ease-out), transform var(--t-press) var(--ease-out);
}
.ledger__row::before {
content: "";
@ -666,30 +875,50 @@ body::before {
inset: 0;
background: var(--grad-brand);
opacity: 0;
transition: opacity var(--t);
transition: opacity var(--t-mid) var(--ease-out);
z-index: -1;
}
.ledger__row:hover { padding-left: 1.4rem; }
.ledger__row:hover::before { opacity: 0.08; }
.ledger__no { font-family: var(--font-mono); font-size: var(--step--1); color: var(--c-text-faint); }
/* accent bar that wipes in from the left on hover */
.ledger__row::after {
content: "";
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--grad-brand);
transform: scaleY(0);
transform-origin: center;
transition: transform var(--t-mid) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.ledger__row:hover { padding-left: 1.4rem; }
.ledger__row:hover::before { opacity: 0.08; }
.ledger__row:hover::after { transform: scaleY(1); }
}
.ledger__row:active { transform: scale(0.992); transition-duration: var(--t-press); }
.ledger__no { font-family: var(--font-mono); font-size: var(--step--1); color: var(--c-text-faint); transition: color var(--t-mid) var(--ease-out); }
.ledger__name {
font-size: var(--step-2);
transition: color var(--t);
transition: color var(--t-mid) var(--ease-out);
}
.ledger__row:hover .ledger__name {
color: transparent;
background: var(--grad-text);
-webkit-background-clip: text;
background-clip: text;
@media (hover: hover) and (pointer: fine) {
.ledger__row:hover .ledger__no { color: var(--emerald); }
.ledger__row:hover .ledger__name {
color: transparent;
background: var(--grad-text);
-webkit-background-clip: text;
background-clip: text;
}
}
.ledger__desc { color: var(--c-text-dim); font-size: var(--step-0); }
.ledger__arrow {
justify-self: end;
font-size: 1.4rem;
color: var(--c-text-faint);
transition: transform var(--t), color var(--t);
transition: transform var(--t-mid) var(--ease-out), color var(--t-mid) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.ledger__row:hover .ledger__arrow { color: var(--violet); transform: translate(5px, -5px); }
}
.ledger__row:hover .ledger__arrow { color: var(--violet); transform: translate(4px, -4px); }
@media (max-width: 760px) {
.ledger__row {
grid-template-columns: 3rem 1fr 2rem;
@ -714,7 +943,7 @@ body::before {
.score__num { font-size: var(--step-4); line-height: 1; font-variant-numeric: tabular-nums; }
.score__num.is-accent {
color: transparent;
background: var(--grad-text);
background: var(--grad-text-ink); /* dark gradient => legible on light paper */
-webkit-background-clip: text;
background-clip: text;
}
@ -727,7 +956,22 @@ body::before {
}
.score__chart { margin-top: clamp(2.5rem, 5vw, 4rem); max-width: 600px; margin-inline: auto; }
.score__bars { display: flex; align-items: flex-end; gap: clamp(0.6rem, 2vw, 1.4rem); height: 130px; }
.score-bar { flex: 1; border-radius: 6px 6px 0 0; background: var(--grad-brand); opacity: 0.85; }
.score-bar {
position: relative;
flex: 1;
border-radius: 6px 6px 0 0;
background: var(--grad-brand);
opacity: 0.9;
}
.score-bar__cap {
position: absolute;
top: -5px; left: 50%;
width: 8px; height: 8px;
margin-left: -4px;
border-radius: 50%;
background: var(--emerald);
box-shadow: 0 0 10px rgba(16, 185, 129, 0.7);
}
.score__axis { width: 100%; height: 8px; margin-top: 4px; }
.score__baseline { stroke: var(--paper-ink); stroke-width: 2; }
@media (max-width: 720px) {
@ -754,11 +998,17 @@ body::before {
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
border: 1px solid var(--c-line);
overflow: hidden;
transition: border-color var(--t), box-shadow var(--t);
transition:
border-color var(--t-mid) var(--ease-out),
box-shadow var(--t-slow) var(--ease-out),
transform var(--t-mid) var(--ease-out);
}
.case:hover .case__inner {
border-color: color-mix(in srgb, var(--case-accent) 55%, transparent);
box-shadow: 0 24px 60px -24px color-mix(in srgb, var(--case-accent) 60%, transparent);
@media (hover: hover) and (pointer: fine) {
.case:hover .case__inner {
border-color: color-mix(in srgb, var(--case-accent) 55%, transparent);
box-shadow: 0 28px 70px -24px color-mix(in srgb, var(--case-accent) 60%, transparent);
transform: translateZ(40px);
}
}
.case__glare { position: absolute; inset: 0; pointer-events: none; }
.case__head {
@ -786,10 +1036,14 @@ body::before {
background: linear-gradient(var(--case-accent), color-mix(in srgb, var(--case-accent) 30%, transparent));
transform: scaleY(0);
transform-origin: bottom;
animation: barGrow 0.7s var(--ease) forwards;
animation: barGrow 0.85s var(--ease-out) forwards;
animation-delay: var(--d);
}
@keyframes barGrow { to { transform: scaleY(1); } }
@keyframes barGrow {
0% { transform: scaleY(0); }
72% { transform: scaleY(1.04); }
100% { transform: scaleY(1); }
}
.case__spark { width: 100%; height: 26px; margin-top: 8px; }
.case__body { position: relative; z-index: 1; }
.case__problem {
@ -840,17 +1094,43 @@ body::before {
background: radial-gradient(circle, rgba(139, 92, 246, 0.1), transparent 70%);
}
.loop-glyph svg { width: 70%; filter: drop-shadow(0 10px 30px rgba(139, 92, 246, 0.4)); }
.loop__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.5rem; }
.loop__count {
display: inline-flex;
align-items: baseline;
gap: 0.2rem;
font-family: var(--font-mono);
font-size: var(--step-2);
font-weight: 600;
color: var(--paper-ink);
letter-spacing: -0.02em;
}
.loop__count-cur { color: var(--violet-600); font-variant-numeric: tabular-nums; }
.loop__count-sep, .loop__count-tot { color: var(--paper-dim); }
.loop__steps { position: relative; padding-left: 2.4rem; }
.loop-rail { position: absolute; left: 0.7rem; top: 0.5rem; bottom: 0.5rem; width: 2px; background: var(--paper-line); }
.loop-rail__fill { position: absolute; inset: 0; background: var(--grad-brand); transform: scaleY(0); transform-origin: top; }
.loop-rail { position: absolute; left: 0.7rem; top: 0.5rem; bottom: 0.5rem; width: 2px; background: var(--paper-line); border-radius: 2px; }
.loop-rail__fill { position: absolute; inset: 0; background: var(--grad-brand); transform: scaleY(0); transform-origin: top; border-radius: 2px; }
.loop-rail__node {
position: absolute;
left: 50%; top: 0;
width: 12px; height: 12px;
margin: -6px 0 0 -6px;
border-radius: 50%;
background: var(--violet-600);
box-shadow: 0 0 0 4px rgba(124, 58, 237, 0.18), 0 0 14px rgba(124, 58, 237, 0.5);
}
.loop__steps ol { display: flex; flex-direction: column; gap: clamp(1.2rem, 2.5vw, 2rem); }
.loop-step {
display: flex;
gap: 1.2rem;
opacity: 0.4;
transition: opacity 0.4s var(--ease), transform 0.4s var(--ease);
opacity: 0.32;
filter: blur(1.5px);
transition:
opacity 0.45s var(--ease-out),
transform 0.45s var(--ease-out),
filter 0.45s var(--ease-out);
}
.loop-step.is-active { opacity: 1; transform: translateX(6px); }
.loop-step.is-active { opacity: 1; transform: translateX(8px); filter: blur(0); }
.loop-step__n { font-family: var(--font-mono); font-weight: 600; font-size: var(--step-0); color: var(--paper-dim); }
.loop-step.is-active .loop-step__n { color: var(--violet-600); }
.loop-step__name { font-size: var(--step-2); font-weight: 900; letter-spacing: -0.02em; }
@ -875,9 +1155,41 @@ body::before {
border-radius: var(--radius);
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
border: 1px solid var(--c-line);
overflow: hidden;
transition:
border-color var(--t-mid) var(--ease-out),
box-shadow var(--t-slow) var(--ease-out),
transform var(--t-mid) var(--ease-out);
}
.quote__mark { font-size: 4rem; line-height: 0.5; color: var(--violet); opacity: 0.5; }
.quote__text { font-size: var(--step-1); font-weight: 500; line-height: 1.35; letter-spacing: -0.01em; margin-top: 1rem; }
.quote::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(120% 80% at 100% 0%, rgba(139, 92, 246, 0.12), transparent 60%);
opacity: 0;
transition: opacity var(--t-slow) var(--ease-out);
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.quote:hover {
border-color: var(--c-line-strong);
box-shadow: 0 24px 60px -28px rgba(139, 92, 246, 0.5);
transform: translateY(-4px);
}
.quote:hover::before { opacity: 1; }
}
.quote__mark {
position: relative;
font-size: 4rem;
line-height: 0.5;
color: var(--violet);
opacity: 0.5;
transition: opacity var(--t-mid) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.quote:hover .quote__mark { opacity: 0.85; }
}
.quote__text { position: relative; font-size: var(--step-1); font-weight: 500; line-height: 1.35; letter-spacing: -0.01em; margin-top: 1rem; }
.quote__by {
display: flex;
align-items: center;
@ -911,9 +1223,12 @@ body::before {
font-weight: 700;
letter-spacing: -0.01em;
color: var(--paper-ink);
transition: color var(--t);
transition: color var(--t-mid) var(--ease-out), padding-left var(--t-mid) var(--ease-out);
}
.faq__btn:hover { color: var(--violet-600); }
@media (hover: hover) and (pointer: fine) {
.faq__btn:hover { color: var(--violet-600); padding-left: 0.4rem; }
}
.faq__item.is-open .faq__btn { color: var(--violet-600); }
.faq__icon { position: relative; flex-shrink: 0; width: 20px; height: 20px; }
.faq__icon span {
position: absolute;
@ -921,10 +1236,10 @@ body::before {
width: 14px; height: 2px;
background: currentColor;
transform: translate(-50%, -50%);
transition: transform var(--t);
transition: transform var(--t-mid) var(--ease-drawer);
}
.faq__icon span:last-child { transform: translate(-50%, -50%) rotate(90deg); }
.faq__item.is-open .faq__icon span:last-child { transform: translate(-50%, -50%) rotate(0); }
.faq__item.is-open .faq__icon span:last-child { transform: translate(-50%, -50%) rotate(180deg); }
.faq__panel { overflow: hidden; }
.faq__a { padding-bottom: clamp(1.1rem, 2.5vw, 1.6rem); max-width: 64ch; color: var(--paper-dim); font-size: var(--step-0); }
@ -973,6 +1288,18 @@ body::before {
transition-duration: 0.001ms !important;
}
.hero__h1 { visibility: visible !important; }
.hero__stagger { opacity: 1 !important; }
.case__bar { transform: scaleY(1); animation: none; }
.hero__video { display: none; } /* poster image only */
/* keep decorative loops fully still, not snapping each 0.001ms */
.hero__live,
.hero-svg__dot,
.hero-svg__dot-ring,
.hero__scroll-line,
.marq__star,
.kicker__dot { animation: none !important; }
/* process steps are a plain legible list when motion is off */
.loop-step { opacity: 1 !important; filter: none !important; transform: none !important; }
.loop-rail__fill { transform: scaleY(1) !important; }
.divider__line { stroke-dasharray: none !important; }
}

View file

@ -15,6 +15,7 @@ import CaseCard, { type CaseData } from "./components/CaseCard";
import ProcessLoop from "./components/ProcessLoop";
import Faq from "./components/Faq";
import Magnetic from "./components/Magnetic";
import SectionDivider from "./components/SectionDivider";
const TAPE = [
"Revenue, not vanity metrics",
@ -37,6 +38,14 @@ const INDUSTRIES = [
"Marketplaces",
];
/* Small live-feeling figures that flank the manifesto so the section reads full,
not empty. Illustrative samples, consistent with the rest of the prototype. */
const MANIFESTO_STATS = [
{ k: "Avg. reporting cadence", v: "Monthly" },
{ k: "Channels we run", v: "6+" },
{ k: "Built around", v: "Your revenue" },
];
export default function Page() {
return (
<>
@ -69,6 +78,16 @@ export default function Page() {
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>
{/* stat ribbon — fills the section, adds a "live" texture */}
<Reveal as="ul" className="manifesto__stats" stagger={0.07} delay={120}>
{MANIFESTO_STATS.map((s) => (
<RevealItem as="li" className="manifesto__stat" key={s.k}>
<span className="manifesto__stat-v display">{s.v}</span>
<span className="manifesto__stat-k">{s.k}</span>
</RevealItem>
))}
</Reveal>
</div>
</section>
@ -81,6 +100,7 @@ export default function Page() {
<Reveal as="ul" className="proof__list" stagger={0.06}>
{INDUSTRIES.map((p) => (
<RevealItem as="li" className="proof__item" key={p}>
<span className="proof__item-dot" aria-hidden="true" />
{p}
</RevealItem>
))}
@ -88,6 +108,8 @@ export default function Page() {
</div>
</section>
<SectionDivider label="What we do" />
{/* ===== SERVICES — interactive ledger ===== */}
<section id="services" className="services frame" aria-labelledby="svc-h">
<div className="wrap">
@ -96,9 +118,11 @@ export default function Page() {
<span className="kicker__dot" />
Services / 06
</p>
<h2 id="svc-h" className="display sec-head__title">
How we grow your business
</h2>
<Reveal as="h2" variant="clip">
<span id="svc-h" className="display sec-head__title">
How we grow your business
</span>
</Reveal>
</header>
<Reveal as="ol" className="ledger" stagger={0.07}>
@ -129,9 +153,11 @@ export default function Page() {
<span className="kicker__dot" />
Selected work sample
</p>
<h2 id="work-h" className="display sec-head__title">
Proof, not promises
</h2>
<Reveal as="h2" variant="clip">
<span id="work-h" className="display sec-head__title">
Proof, not promises
</span>
</Reveal>
</header>
<div className="work__grid">
@ -155,6 +181,19 @@ export default function Page() {
{/* ===== TESTIMONIALS ===== */}
<section className="quotes frame" aria-label="What clients say">
<div className="wrap">
<header className="sec-head">
<p className="kicker">
<span className="kicker__dot" />
In their words sample
</p>
<Reveal as="h2" variant="clip">
<span className="display sec-head__title">
The number is the point
</span>
</Reveal>
</header>
</div>
<div className="wrap quotes__grid">
{testimonials.map((t, i) => (
<Reveal as="figure" className="quote" key={i} delay={i * 120}>
@ -184,9 +223,11 @@ export default function Page() {
<span className="kicker__dot" />
FAQ
</p>
<h2 id="faq-h" className="display sec-head__title">
Questions about working with a digital marketing agency
</h2>
<Reveal as="h2" variant="clip">
<span id="faq-h" className="display sec-head__title">
Questions about working with a digital marketing agency
</span>
</Reveal>
</header>
<Faq />
</div>