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 * CASE CARD replaces the rejected repetitive photos with a coded, animated
* data visual unique per case: * data visual unique per case:
* - 3D tilt that tracks the pointer (Motion springs, transform-only). * - 3D tilt that tracks the pointer via a SOFT spring (momentum => the card
* - An animated bars/sparkline "result chart" drawn in CSS + SVG, growing * floats and settles, never snaps). Transform-only.
* into view (no two cards look alike: different bars, colours, labels). * - 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. * - 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 { import {
motion, motion,
useMotionValue, useMotionValue,
@ -17,6 +21,8 @@ import {
useTransform, useTransform,
useReducedMotion, useReducedMotion,
} from "motion/react"; } from "motion/react";
import { gsap } from "./gsap";
import { SPRING, EASE_OUT } from "./motion";
export type CaseData = { export type CaseData = {
tag: string; tag: string;
@ -29,17 +35,23 @@ export type CaseData = {
accent: string; // brand accent for this card 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 reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const mx = useMotionValue(0.5); const mx = useMotionValue(0.5);
const my = useMotionValue(0.5); const my = useMotionValue(0.5);
const rx = useSpring(useTransform(my, [0, 1], [7, -7]), { stiffness: 200, damping: 20 }); const rx = useSpring(useTransform(my, [0, 1], [6, -6]), SPRING.tilt);
const ry = useSpring(useTransform(mx, [0, 1], [-9, 9]), { stiffness: 200, damping: 20 }); const ry = useSpring(useTransform(mx, [0, 1], [-8, 8]), SPRING.tilt);
const glare = useTransform( const glare = useTransform(
[mx, my], [mx, my],
([gx, gy]: number[]) => ([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) => { const onMove = (e: React.PointerEvent) => {
@ -55,26 +67,57 @@ export default function CaseCard({ data, index }: { data: CaseData; index: numbe
my.set(0.5); 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 ( return (
<motion.article <motion.article
ref={ref} ref={ref}
className="case" className="case"
onPointerMove={onMove} onPointerMove={onMove}
onPointerLeave={reset} onPointerLeave={reset}
style={reduce ? undefined : { rotateX: rx, rotateY: ry, transformPerspective: 1000 }} style={
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 40 }} reduce
whileInView={{ opacity: 1, y: 0 }} ? 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" }} 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"> <div className="case__head">
<span className="case__no">{String(index + 1).padStart(2, "0")}</span> <span className="case__no">{String(index + 1).padStart(2, "0")}</span>
<span className="case__tag">{data.tag}</span> <span className="case__tag">{data.tag}</span>
</div> </div>
{/* coded data visual — unique bars per case */} {/* coded data visual — unique bars per case, clip-wiped on first view */}
<div className="case__viz" aria-hidden="true"> <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"> <div className="case__bars">
{data.bars.map((h, i) => ( {data.bars.map((h, i) => (
<span <span
@ -96,7 +139,7 @@ export default function CaseCard({ data, index }: { data: CaseData; index: numbe
strokeLinecap="round" strokeLinecap="round"
/> />
</svg> </svg>
</div> </motion.div>
<div className="case__body"> <div className="case__body">
<p className="case__problem"> <p className="case__problem">

View file

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

View file

@ -1,27 +1,32 @@
"use client"; "use client";
/** /**
* Custom cursor: a spring-following dot + a larger ring. * Custom cursor spring-following dot + trailing ring, the page's "alive" tell.
* - Grows + shows a contextual label when hovering [data-cursor] targets. * - Ring trails with visible momentum (soft spring); dot leads (tighter spring)
* - Hidden on touch / coarse pointers and when prefers-reduced-motion is set * so the two have a believable lead/chase relationship never a rigid pair.
* (falls back to the native cursor, which globals.css restores). * - On [data-cursor] / links / buttons the ring grows + shows a contextual
* - Built with Motion springs for buttery follow without layout thrash * label.
* (transform-only, GPU-friendly). * - 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 { useEffect, useState } from "react";
import { motion, useMotionValue, useSpring } from "motion/react"; import { motion, useMotionValue, useSpring } from "motion/react";
import { SPRING } from "./motion";
export default function Cursor() { export default function Cursor() {
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [hovering, setHovering] = useState(false); const [hovering, setHovering] = useState(false);
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [pressed, setPressed] = useState(false);
const x = useMotionValue(-100); const x = useMotionValue(-100);
const y = useMotionValue(-100); const y = useMotionValue(-100);
const ringX = useSpring(x, { stiffness: 350, damping: 30, mass: 0.6 }); const ringX = useSpring(x, SPRING.cursorRing);
const ringY = useSpring(y, { stiffness: 350, damping: 30, mass: 0.6 }); const ringY = useSpring(y, SPRING.cursorRing);
const dotX = useSpring(x, { stiffness: 900, damping: 40 }); const dotX = useSpring(x, SPRING.cursorDot);
const dotY = useSpring(y, { stiffness: 900, damping: 40 }); const dotY = useSpring(y, SPRING.cursorDot);
useEffect(() => { useEffect(() => {
const fine = window.matchMedia("(pointer: fine)").matches; const fine = window.matchMedia("(pointer: fine)").matches;
@ -44,10 +49,16 @@ export default function Cursor() {
setLabel(""); setLabel("");
} }
}; };
const down = () => setPressed(true);
const up = () => setPressed(false);
window.addEventListener("pointermove", move, { passive: true }); window.addEventListener("pointermove", move, { passive: true });
window.addEventListener("pointerdown", down, { passive: true });
window.addEventListener("pointerup", up, { passive: true });
return () => { return () => {
window.removeEventListener("pointermove", move); window.removeEventListener("pointermove", move);
window.removeEventListener("pointerdown", down);
window.removeEventListener("pointerup", up);
document.documentElement.classList.remove("has-custom-cursor"); document.documentElement.classList.remove("has-custom-cursor");
}; };
}, [x, y]); }, [x, y]);
@ -57,12 +68,17 @@ export default function Cursor() {
return ( return (
<div aria-hidden="true" className="cursor-layer"> <div aria-hidden="true" className="cursor-layer">
<motion.div <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 }} style={{ x: ringX, y: ringY }}
> >
{label && <span className="cursor-ring__label">{label}</span>} {label && <span className="cursor-ring__label">{label}</span>}
</motion.div> </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> </div>
); );
} }

View file

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

View file

@ -1,14 +1,17 @@
"use client"; "use client";
/** /**
* HERO the signature moment. Fixes the client's 4 complaints head-on: * HERO the signature moment. One orchestrated, choreographed page-load with
* (2) real background VIDEO (autoplay/muted/loop/playsinline, poster, cover, * staggered reveals (the single highest-impact "alive" moment on the page).
* deferred load, reduced-motion -> poster still only). * - Real background VIDEO (autoplay/muted/loop/playsinline, poster, cover,
* (3) ANIMATED SVG: a revenue growth line that draws itself (GSAP DrawSVG) * deferred load via IO, paused on hidden tab; reduced-motion -> poster only).
* with an area fill that fades up and a pulsing "live" marker. * - ANIMATED SVG: a revenue growth line that DRAWS itself (GSAP DrawSVG) with
* (1) real interaction: SplitText word reveal, cursor spotlight following the * an area fill that fades up, grid ticks that draw in, and a "live" marker
* pointer, magnetic CTAs, parallax on scroll. * that springs in then pulses.
* (4) zero raster imagery beyond the video poster. * - 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 { useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
@ -16,17 +19,26 @@ import { gsap, SplitText } from "./gsap";
import Magnetic from "./Magnetic"; import Magnetic from "./Magnetic";
import { SITE } from "../content"; 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() { export default function Hero() {
const root = useRef<HTMLDivElement>(null); const root = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(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 onPointerMove = (e: React.PointerEvent) => {
const el = root.current; const el = root.current;
if (!el) return; if (!el) return;
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
el.style.setProperty("--mx", `${((e.clientX - r.left) / r.width) * 100}%`); target.current.x = ((e.clientX - r.left) / r.width) * 100;
el.style.setProperty("--my", `${((e.clientY - r.top) / r.height) * 100}%`); target.current.y = ((e.clientY - r.top) / r.height) * 100;
}; };
useEffect(() => { useEffect(() => {
@ -34,92 +46,138 @@ export default function Hero() {
if (!el) return; if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Defer the video: only start it once it's actually on screen + ready, // --- spotlight: lerp current toward target each frame (momentum) ---
// and pause it when the tab/section is hidden (saves battery + main thread). let rafId = 0;
const video = videoRef.current; if (!reduce) {
if (video && !reduce) { const tick = () => {
const io = new IntersectionObserver( current.current.x += (target.current.x - current.current.x) * 0.08;
([entry]) => { current.current.y += (target.current.y - current.current.y) * 0.08;
if (entry.isIntersecting) video.play().catch(() => {}); el.style.setProperty("--mx", `${current.current.x}%`);
else video.pause(); el.style.setProperty("--my", `${current.current.y}%`);
}, rafId = requestAnimationFrame(tick);
{ 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); 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(() => { const ctx = gsap.context(() => {
// 1) Headline: split into words/lines and reveal with a mask. // 1) Headline: split into lines+words, reveal each line from behind a
const split = new SplitText(".hero__h1", { type: "lines,words" }); // 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__h1", { autoAlpha: 1 });
gsap.set(".hero__line", { overflow: "hidden" });
gsap.from(split.words, { gsap.from(split.words, {
yPercent: 120, yPercent: 118,
opacity: 0, 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, duration: 1,
ease: "expo.out", ease: "emilOut",
stagger: 0.04, stagger: 0.07,
delay: 0.15, delay: 0.55,
startAt: { y: 26, filter: "blur(5px)" },
}); });
// 2) Staggered entrance for eyebrow, sub, CTAs, trust row. // 3) ANIMATED SVG — grid draws, the revenue line draws itself, area
gsap.from(".hero__stagger", { // fades up, the live marker springs in (back ease => overshoot).
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 }); 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", { tl.from(".hero-svg__grid line", {
drawSVG: "0%", drawSVG: "0%",
duration: 0.8, duration: 0.9,
stagger: 0.04, stagger: 0.06,
ease: "power2.out", ease: "power2.out",
}) })
.from( .from(
".hero-svg__line", ".hero-svg__line",
{ drawSVG: "0%", duration: 1.8, ease: "power2.inOut" }, { drawSVG: "0%", duration: 2, ease: "power2.inOut" },
"-=0.4" "-=0.5"
) )
.to(".hero-svg__area", { autoAlpha: 1, duration: 0.9 }, "-=1.1") .to(".hero-svg__area", { autoAlpha: 1, duration: 1 }, "-=1.2")
.from( .from(
".hero-svg__dot", ".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" "-=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", { gsap.to(".hero__media", {
yPercent: 18, yPercent: 16,
ease: "none", 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", { gsap.to(".hero__content", {
yPercent: -8, yPercent: -10,
opacity: 0.4, opacity: 0.3,
ease: "none", 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); }, el);
return () => { return () => {
io.disconnect(); cancelAnimationFrame(rafId);
document.removeEventListener("visibilitychange", onVis); io?.disconnect();
if (onVis) document.removeEventListener("visibilitychange", onVis);
ctx.revert(); ctx.revert();
}; };
} }
// Reduced motion: still reveal the SVG line statically (no draw), show poster. // Reduced motion: reveal everything statically, show poster only.
gsap.set([".hero__h1", ".hero__stagger", ".hero-svg__area"], { autoAlpha: 1 }); gsap.set([".hero__h1", ".hero__stagger", ".hero-svg__area"], {
autoAlpha: 1,
y: 0,
filter: "none",
});
return () => cancelAnimationFrame(rafId);
}, []); }, []);
return ( return (
@ -144,6 +202,7 @@ export default function Hero() {
</video> </video>
<div className="hero__scrim" /> <div className="hero__scrim" />
<div className="hero__spotlight" /> <div className="hero__spotlight" />
<div className="hero__noise" />
</div> </div>
{/* ---- animated SVG growth chart ---- */} {/* ---- 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="300" x2="1200" y2="300" />
<line x1="0" y1="450" x2="1200" y2="450" /> <line x1="0" y1="450" x2="1200" y2="450" />
</g> </g>
{/* area under the curve */}
<path <path
className="hero-svg__area" className="hero-svg__area"
fill="url(#heroArea)" 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" 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 <path
className="hero-svg__line" className="hero-svg__line"
fill="none" fill="none"
@ -184,6 +241,7 @@ export default function Hero() {
strokeLinecap="round" 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" 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" /> <circle className="hero-svg__dot" cx="1200" cy="86" r="9" fill="#10b981" />
</svg> </svg>
@ -205,7 +263,11 @@ export default function Hero() {
<div className="hero__cta hero__stagger"> <div className="hero__cta hero__stagger">
<Magnetic strength={0.4}> <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 Get your growth audit
</Link> </Link>
</Magnetic> </Magnetic>
@ -229,7 +291,11 @@ export default function Hero() {
</dl> </dl>
</div> </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" /> <span className="hero__scroll-line" aria-hidden="true" />
Scroll Scroll
</a> </a>

View file

@ -1,17 +1,20 @@
"use client"; "use client";
/** /**
* Magnetic wrapper: the child is pulled toward the cursor while hovered, * Magnetic wrapper the child is pulled toward the cursor while hovered, then
* then springs back on leave. Transform-only (GPU). No-op on coarse pointers * springs back with momentum on leave (loose spring => visible overshoot, the
* and with prefers-reduced-motion, so keyboard/touch users get a static, * "alive" feel). The label inside also drifts at a deeper strength for a subtle
* fully-clickable element. * 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 { 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({ export default function Magnetic({
children, children,
strength = 0.4, strength = 0.35,
className, className,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
@ -21,8 +24,11 @@ export default function Magnetic({
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
const x = useMotionValue(0); const x = useMotionValue(0);
const y = useMotionValue(0); const y = useMotionValue(0);
const sx = useSpring(x, { stiffness: 250, damping: 18, mass: 0.4 }); const sx = useSpring(x, SPRING.magnetic);
const sy = useSpring(y, { stiffness: 250, damping: 18, mass: 0.4 }); 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) => { const onMove = (e: React.PointerEvent) => {
if (e.pointerType !== "mouse") return; if (e.pointerType !== "mouse") return;
@ -47,7 +53,9 @@ export default function Magnetic({
onPointerLeave={reset} onPointerLeave={reset}
style={{ x: sx, y: sy, display: "inline-block" }} style={{ x: sx, y: sy, display: "inline-block" }}
> >
{children} <motion.span style={{ x: innerX, y: innerY, display: "inline-block" }}>
{children}
</motion.span>
</motion.span> </motion.span>
); );
} }

View file

@ -62,18 +62,33 @@ export default function ProcessLoop() {
}); });
steps[0]?.classList.add("is-active"); 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) => { SHAPES.forEach((shape, i) => {
if (i === 0) return; // start shape already in markup if (i === 0) return; // start shape already in markup
const at = i - 1; 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) 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( .to(
".loop-rail__fill", ".loop-rail__fill",
{ scaleY: i / (total - 1), duration: 1, ease: "none" }, { scaleY: i / (total - 1), duration: 1, ease: "none" },
at 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); }, el);
return () => ctx.revert(); 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"> <section ref={root} id="process" className="loop frame" data-invert aria-labelledby="loop-h">
<div className="loop-pin"> <div className="loop-pin">
<div className="wrap loop__inner"> <div className="wrap loop__inner">
<header className="sec-head"> <header className="sec-head loop__head">
<p className="kicker"> <div>
<span className="kicker__dot" /> <p className="kicker">
The Feedback Loop <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> </p>
<h2 id="loop-h" className="display sec-head__title">
How it works
</h2>
</header> </header>
<div className="loop__stage"> <div className="loop__stage">
@ -112,6 +134,7 @@ export default function ProcessLoop() {
<div className="loop__steps"> <div className="loop__steps">
<div className="loop-rail" aria-hidden="true"> <div className="loop-rail" aria-hidden="true">
<span className="loop-rail__fill" /> <span className="loop-rail__fill" />
<span className="loop-rail__node" />
</div> </div>
<ol> <ol>
{processSteps.map((p) => ( {processSteps.map((p) => (

View file

@ -1,13 +1,18 @@
"use client"; "use client";
/** /**
* In-view reveal. Fades + lifts content when it scrolls into view (once). * In-view reveal the page's core entrance vocabulary.
* - Motion `whileInView` with a viewport margin so it triggers slightly early. * - Default: lift + fade + a touch of blur(4px -> 0) so content resolves INTO
* - `stagger` cascades direct children via Motion variants. * place instead of just sliding (Emil's blur-masked entrance). Never appears
* - Honors prefers-reduced-motion (renders fully visible, no transform). * 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 { motion, type Variants, useReducedMotion } from "motion/react";
import { useReducedMotion } from "motion/react"; import { EASE_OUT } from "./motion";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
@ -15,15 +20,29 @@ type Props = {
delay?: number; delay?: number;
y?: number; y?: number;
stagger?: 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({ export default function Reveal({
children, children,
className, className,
delay = 0, delay = 0,
y = 26, y = 28,
stagger, stagger,
variant = "lift",
as = "div", as = "div",
}: Props) { }: Props) {
const reduce = useReducedMotion(); const reduce = useReducedMotion();
@ -32,7 +51,12 @@ export default function Reveal({
if (stagger) { if (stagger) {
const parent: Variants = { const parent: Variants = {
hidden: {}, hidden: {},
show: { transition: { staggerChildren: reduce ? 0 : stagger, delayChildren: delay } }, show: {
transition: {
staggerChildren: reduce ? 0 : stagger,
delayChildren: delay / 1000,
},
},
}; };
return ( return (
<MotionTag <MotionTag
@ -40,20 +64,34 @@ export default function Reveal({
variants={parent} variants={parent}
initial="hidden" initial="hidden"
whileInView="show" whileInView="show"
viewport={{ once: true, margin: "0px 0px -12% 0px" }} viewport={VIEWPORT}
> >
{children} {children}
</MotionTag> </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 ( return (
<MotionTag <MotionTag
className={className} className={className}
initial={reduce ? { opacity: 1 } : { opacity: 0, y }} initial={hidden}
whileInView={{ opacity: 1, y: 0 }} whileInView={shown}
viewport={{ once: true, margin: "0px 0px -12% 0px" }} viewport={VIEWPORT}
transition={{ duration: 0.7, delay: delay / 1000, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 0.9, delay: delay / 1000, ease: EASE_OUT }}
> >
{children} {children}
</MotionTag> </MotionTag>
@ -64,7 +102,7 @@ export default function Reveal({
export function RevealItem({ export function RevealItem({
children, children,
className, className,
y = 22, y = 24,
as = "div", as = "div",
}: { }: {
children: React.ReactNode; children: React.ReactNode;
@ -75,8 +113,13 @@ export function RevealItem({
const reduce = useReducedMotion(); const reduce = useReducedMotion();
const MotionTag = motion[as] as typeof motion.div; const MotionTag = motion[as] as typeof motion.div;
const item: Variants = { const item: Variants = {
hidden: reduce ? { opacity: 1 } : { opacity: 0, y }, hidden: reduce ? { opacity: 1 } : { opacity: 0, y, filter: "blur(4px)" },
show: { opacity: 1, y: 0, transition: { duration: 0.65, ease: [0.16, 1, 0.3, 1] } }, show: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.75, ease: EASE_OUT },
},
}; };
return ( return (
<MotionTag className={className} variants={item}> <MotionTag className={className} variants={item}>

View file

@ -25,19 +25,38 @@ export default function Scoreboard() {
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
const tl = gsap.timeline({ 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", { // Baseline draws first, then bars grow up off it with a tight stagger and
scaleY: 0, // a soft landing (back ease => a hair of overshoot so they feel physical).
transformOrigin: "bottom", tl.from(".score__baseline", {
duration: 0.9, drawSVG: "0%",
ease: "expo.out", duration: 0.8,
stagger: 0.08, ease: "power2.out",
}).from( })
".score__baseline", .from(
{ drawSVG: "0%", duration: 0.9, ease: "power2.out" }, ".score-bar",
"-=0.7" {
); 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); }, el);
return () => ctx.revert(); return () => ctx.revert();
@ -81,7 +100,9 @@ export default function Scoreboard() {
<div className="score__chart" aria-hidden="true"> <div className="score__chart" aria-hidden="true">
<div className="score__bars"> <div className="score__bars">
{BARS.map((h, i) => ( {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> </div>
<svg className="score__axis" viewBox="0 0 600 8" preserveAspectRatio="none"> <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() { export default function SiteHeader() {
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [active, setActive] = useState<string>("");
const toggleRef = useRef<HTMLButtonElement>(null); const toggleRef = useRef<HTMLButtonElement>(null);
useEffect(() => { useEffect(() => {
@ -31,6 +32,26 @@ export default function SiteHeader() {
return () => window.removeEventListener("scroll", onScroll); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
@ -60,13 +81,20 @@ export default function SiteHeader() {
<nav className="site-nav" aria-label="Primary"> <nav className="site-nav" aria-label="Primary">
<ul> <ul>
{NAV.map((n) => ( {NAV.map((n) => {
<li key={n.href}> const isActive = active === n.href;
<a href={n.href} data-cursor=""> return (
{n.label} <li key={n.href}>
</a> <a
</li> href={n.href}
))} className={isActive ? "is-active" : ""}
aria-current={isActive ? "true" : undefined}
>
{n.label}
</a>
</li>
);
})}
</ul> </ul>
</nav> </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 */ /* gradients */
--grad-brand: linear-gradient(100deg, var(--blue), var(--violet) 52%, var(--emerald)); --grad-brand: linear-gradient(100deg, var(--blue), var(--violet) 52%, var(--emerald));
--grad-text: linear-gradient(100deg, #7cb2ff, #b69bff 50%, #4ee3ad); --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 */ /* inverted (light "paper") sections */
--paper: #f4f1ea; --paper: #f4f1ea;
@ -66,9 +68,21 @@
--section-y: clamp(4.5rem, 9vw, 9rem); --section-y: clamp(4.5rem, 9vw, 9rem);
--radius: 18px; --radius: 18px;
/* motion */ /* motion Emil Kowalski craft easing system.
--ease: cubic-bezier(0.16, 1, 0.3, 1); Built-in CSS easings are too weak; these are the agency's signature curves.
--t: 0.3s var(--ease); 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; --scroll-progress: 0;
color-scheme: dark; color-scheme: dark;
@ -103,10 +117,14 @@ a { color: inherit; text-decoration: none; }
ul, ol { list-style: none; padding: 0; } ul, ol { list-style: none; padding: 0; }
button { font: inherit; color: inherit; cursor: pointer; background: none; border: none; } button { font: inherit; color: inherit; cursor: pointer; background: none; border: none; }
:focus-visible { :focus-visible {
outline: 3px solid var(--color-accent); outline: 3px solid #6ee7b7; /* emerald-300: ~6:1 on the dark canvas */
outline-offset: 3px; outline-offset: 3px;
border-radius: 4px; 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 { .sr-only {
position: absolute; position: absolute;
width: 1px; height: 1px; width: 1px; height: 1px;
@ -223,6 +241,8 @@ body::before {
} }
[data-invert] .kicker { color: var(--paper-dim); } [data-invert] .kicker { color: var(--paper-dim); }
[data-invert] .rule { background: var(--paper-line); } [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 5. BUTTONS
@ -238,35 +258,66 @@ body::before {
font-size: var(--step-0); font-size: var(--step-0);
letter-spacing: -0.01em; letter-spacing: -0.01em;
border: 1.5px solid transparent; border: 1.5px solid transparent;
transition: transform var(--t), box-shadow var(--t), background var(--t), transition:
border-color var(--t), color var(--t); 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; will-change: transform;
overflow: hidden;
} }
.btn--sm { padding: 0.6rem 1.1rem; font-size: var(--step--1); } .btn--sm { padding: 0.6rem 1.1rem; font-size: var(--step--1); }
.btn--accent { .btn--accent {
color: #fff; color: #fff;
background: var(--grad-brand); background: var(--grad-brand);
background-size: 160% 160%; background-size: 200% 200%;
background-position: 0% 50%; 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 { /* sheen that sweeps across on hover — a small, deliberate detail */
background-position: 100% 50%; .btn--accent::after {
box-shadow: 0 14px 40px -8px rgba(139, 92, 246, 0.7); 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 { .btn--ghost {
color: var(--c-text); color: var(--c-text);
border-color: var(--c-line-strong); border-color: var(--c-line-strong);
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
} }
.btn--ghost:hover { @media (hover: hover) and (pointer: fine) {
border-color: var(--violet); .btn--ghost:hover {
background: rgba(139, 92, 246, 0.1); 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 { 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 6. CUSTOM CURSOR
@ -293,14 +344,21 @@ body::before {
margin: -3.5px 0 0 -3.5px; margin: -3.5px 0 0 -3.5px;
background: var(--emerald); background: var(--emerald);
mix-blend-mode: difference; 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 { .cursor-ring {
width: 38px; height: 38px; width: 38px; height: 38px;
margin: -19px 0 0 -19px; margin: -19px 0 0 -19px;
border: 1.5px solid rgba(255, 255, 255, 0.55); border: 1.5px solid rgba(255, 255, 255, 0.55);
display: grid; place-items: center; display: grid; place-items: center;
transition: width 0.25s var(--ease), height 0.25s var(--ease), transition:
background 0.25s var(--ease), border-color 0.25s var(--ease); 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 { .cursor-ring.is-hover {
width: 56px; height: 56px; width: 56px; height: 56px;
@ -314,6 +372,8 @@ body::before {
background: var(--violet); background: var(--violet);
border-color: 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 { .cursor-ring__label {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
@ -368,8 +428,24 @@ body::before {
width: 30px; height: 30px; width: 30px; height: 30px;
border-radius: 9px; border-radius: 9px;
background: var(--grad-brand); 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); } .brand__name { font-size: var(--step-0); }
.site-nav { margin-left: auto; } .site-nav { margin-left: auto; }
.site-nav ul { .site-nav ul {
@ -381,21 +457,26 @@ body::before {
.site-nav a { .site-nav a {
position: relative; position: relative;
color: var(--c-text-dim); color: var(--c-text-dim);
transition: color var(--t); transition: color var(--t-mid) var(--ease-out);
padding-block: 0.3rem; padding-block: 0.3rem;
} }
.site-nav a::after { .site-nav a::after {
content: ""; content: "";
position: absolute; position: absolute;
left: 0; bottom: 0; left: 0; bottom: 0;
height: 1.5px; width: 0; height: 1.5px; width: 100%;
background: var(--violet); 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:hover,
.site-nav a:focus-visible { color: var(--c-text); } .site-nav a:focus-visible { color: var(--c-text); }
.site-nav a:hover::after, .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 { .burger {
display: none; display: none;
width: 44px; height: 44px; width: 44px; height: 44px;
@ -407,10 +488,11 @@ body::before {
.burger span { .burger span {
width: 22px; height: 2px; width: 22px; height: 2px;
background: var(--c-text); 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(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); } .burger.is-open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.mobile-menu { display: none; } .mobile-menu { display: none; }
@media (max-width: 860px) { @media (max-width: 860px) {
@ -452,20 +534,28 @@ body::before {
--mx: 50%; --mx: 50%;
--my: 40%; --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__video { width: 100%; height: 100%; object-fit: cover; opacity: 0.55; }
.hero__scrim { .hero__scrim {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: background:
linear-gradient(180deg, rgba(7, 7, 11, 0.55), rgba(7, 7, 11, 0.82) 60%, var(--c-bg)), 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.7), transparent 60%); linear-gradient(90deg, rgba(7, 7, 11, 0.72), transparent 62%);
} }
.hero__spotlight { .hero__spotlight {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: radial-gradient(420px circle at var(--mx) var(--my), rgba(139, 92, 246, 0.22), transparent 60%); background: radial-gradient(440px circle at var(--mx) var(--my), rgba(139, 92, 246, 0.24), transparent 60%);
transition: background 0.15s linear; 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 { .hero-svg {
position: absolute; 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__line { filter: drop-shadow(0 0 14px rgba(139, 92, 246, 0.5)); }
.hero-svg__dot { .hero-svg__dot {
filter: drop-shadow(0 0 12px rgba(16, 185, 129, 0.9)); 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 { @keyframes heroPulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.6; } 50% { opacity: 0.6; }
} }
@keyframes heroPing {
0% { transform: scale(0.6); opacity: 0.7; }
70%, 100% { transform: scale(2.1); opacity: 0; }
}
.hero__content { .hero__content {
position: relative; position: relative;
z-index: 2; z-index: 2;
@ -522,6 +622,15 @@ body::before {
max-width: 15ch; max-width: 15ch;
visibility: hidden; /* revealed by GSAP; reduced-motion fallback re-shows */ 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 { .hero__sub {
margin-top: 1.6rem; margin-top: 1.6rem;
max-width: 46ch; max-width: 46ch;
@ -602,7 +711,18 @@ body::before {
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--c-text); 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 10. MANIFESTO
@ -618,6 +738,76 @@ body::before {
color: var(--c-text-dim); color: var(--c-text-dim);
max-width: 52ch; 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 11. PROOF / INDUSTRIES
@ -631,19 +821,38 @@ body::before {
.proof__label { display: block; margin-bottom: 1.6rem; } .proof__label { display: block; margin-bottom: 1.6rem; }
.proof__list { display: flex; flex-wrap: wrap; gap: 0.7rem 0.9rem; } .proof__list { display: flex; flex-wrap: wrap; gap: 0.7rem 0.9rem; }
.proof__item { .proof__item {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 1.1rem; padding: 0.55rem 1.1rem;
border: 1px solid var(--c-line-strong); border: 1px solid var(--c-line-strong);
border-radius: 999px; border-radius: 999px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--step--1); font-size: var(--step--1);
color: var(--c-text-dim); 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 { .proof__item-dot {
color: var(--c-text); width: 6px; height: 6px;
border-color: var(--violet); border-radius: 50%;
background: rgba(139, 92, 246, 0.08); background: var(--c-text-faint);
transform: translateY(-2px); 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); padding-block: clamp(1.4rem, 3vw, 2.2rem);
border-bottom: 1px solid var(--c-line); border-bottom: 1px solid var(--c-line);
position: relative; 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 { .ledger__row::before {
content: ""; content: "";
@ -666,30 +875,50 @@ body::before {
inset: 0; inset: 0;
background: var(--grad-brand); background: var(--grad-brand);
opacity: 0; opacity: 0;
transition: opacity var(--t); transition: opacity var(--t-mid) var(--ease-out);
z-index: -1; z-index: -1;
} }
.ledger__row:hover { padding-left: 1.4rem; } /* accent bar that wipes in from the left on hover */
.ledger__row:hover::before { opacity: 0.08; } .ledger__row::after {
.ledger__no { font-family: var(--font-mono); font-size: var(--step--1); color: var(--c-text-faint); } 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 { .ledger__name {
font-size: var(--step-2); font-size: var(--step-2);
transition: color var(--t); transition: color var(--t-mid) var(--ease-out);
} }
.ledger__row:hover .ledger__name { @media (hover: hover) and (pointer: fine) {
color: transparent; .ledger__row:hover .ledger__no { color: var(--emerald); }
background: var(--grad-text); .ledger__row:hover .ledger__name {
-webkit-background-clip: text; color: transparent;
background-clip: text; background: var(--grad-text);
-webkit-background-clip: text;
background-clip: text;
}
} }
.ledger__desc { color: var(--c-text-dim); font-size: var(--step-0); } .ledger__desc { color: var(--c-text-dim); font-size: var(--step-0); }
.ledger__arrow { .ledger__arrow {
justify-self: end; justify-self: end;
font-size: 1.4rem; font-size: 1.4rem;
color: var(--c-text-faint); 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) { @media (max-width: 760px) {
.ledger__row { .ledger__row {
grid-template-columns: 3rem 1fr 2rem; 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 { font-size: var(--step-4); line-height: 1; font-variant-numeric: tabular-nums; }
.score__num.is-accent { .score__num.is-accent {
color: transparent; color: transparent;
background: var(--grad-text); background: var(--grad-text-ink); /* dark gradient => legible on light paper */
-webkit-background-clip: text; -webkit-background-clip: text;
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__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__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__axis { width: 100%; height: 8px; margin-top: 4px; }
.score__baseline { stroke: var(--paper-ink); stroke-width: 2; } .score__baseline { stroke: var(--paper-ink); stroke-width: 2; }
@media (max-width: 720px) { @media (max-width: 720px) {
@ -754,11 +998,17 @@ body::before {
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2)); background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
border: 1px solid var(--c-line); border: 1px solid var(--c-line);
overflow: hidden; 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 { @media (hover: hover) and (pointer: fine) {
border-color: color-mix(in srgb, var(--case-accent) 55%, transparent); .case:hover .case__inner {
box-shadow: 0 24px 60px -24px color-mix(in srgb, var(--case-accent) 60%, transparent); 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__glare { position: absolute; inset: 0; pointer-events: none; }
.case__head { .case__head {
@ -786,10 +1036,14 @@ body::before {
background: linear-gradient(var(--case-accent), color-mix(in srgb, var(--case-accent) 30%, transparent)); background: linear-gradient(var(--case-accent), color-mix(in srgb, var(--case-accent) 30%, transparent));
transform: scaleY(0); transform: scaleY(0);
transform-origin: bottom; transform-origin: bottom;
animation: barGrow 0.7s var(--ease) forwards; animation: barGrow 0.85s var(--ease-out) forwards;
animation-delay: var(--d); 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__spark { width: 100%; height: 26px; margin-top: 8px; }
.case__body { position: relative; z-index: 1; } .case__body { position: relative; z-index: 1; }
.case__problem { .case__problem {
@ -840,17 +1094,43 @@ body::before {
background: radial-gradient(circle, rgba(139, 92, 246, 0.1), transparent 70%); 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-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__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 { 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; } .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__steps ol { display: flex; flex-direction: column; gap: clamp(1.2rem, 2.5vw, 2rem); }
.loop-step { .loop-step {
display: flex; display: flex;
gap: 1.2rem; gap: 1.2rem;
opacity: 0.4; opacity: 0.32;
transition: opacity 0.4s var(--ease), transform 0.4s var(--ease); 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__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.is-active .loop-step__n { color: var(--violet-600); }
.loop-step__name { font-size: var(--step-2); font-weight: 900; letter-spacing: -0.02em; } .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); border-radius: var(--radius);
background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2)); background: linear-gradient(180deg, var(--c-surface), var(--c-bg-2));
border: 1px solid var(--c-line); 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::before {
.quote__text { font-size: var(--step-1); font-weight: 500; line-height: 1.35; letter-spacing: -0.01em; margin-top: 1rem; } 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 { .quote__by {
display: flex; display: flex;
align-items: center; align-items: center;
@ -911,9 +1223,12 @@ body::before {
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: var(--paper-ink); 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 { position: relative; flex-shrink: 0; width: 20px; height: 20px; }
.faq__icon span { .faq__icon span {
position: absolute; position: absolute;
@ -921,10 +1236,10 @@ body::before {
width: 14px; height: 2px; width: 14px; height: 2px;
background: currentColor; background: currentColor;
transform: translate(-50%, -50%); 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__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__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); } .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; transition-duration: 0.001ms !important;
} }
.hero__h1 { visibility: visible !important; } .hero__h1 { visibility: visible !important; }
.hero__stagger { opacity: 1 !important; }
.case__bar { transform: scaleY(1); animation: none; } .case__bar { transform: scaleY(1); animation: none; }
.hero__video { display: none; } /* poster image only */ .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 ProcessLoop from "./components/ProcessLoop";
import Faq from "./components/Faq"; import Faq from "./components/Faq";
import Magnetic from "./components/Magnetic"; import Magnetic from "./components/Magnetic";
import SectionDivider from "./components/SectionDivider";
const TAPE = [ const TAPE = [
"Revenue, not vanity metrics", "Revenue, not vanity metrics",
@ -37,6 +38,14 @@ const INDUSTRIES = [
"Marketplaces", "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() { export default function Page() {
return ( return (
<> <>
@ -69,6 +78,16 @@ export default function Page() {
built to move revenue, and we report on it the way your CFO would. 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. No vanity metrics. No mystery. Just the number that pays the bills.
</Reveal> </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> </div>
</section> </section>
@ -81,6 +100,7 @@ export default function Page() {
<Reveal as="ul" className="proof__list" stagger={0.06}> <Reveal as="ul" className="proof__list" stagger={0.06}>
{INDUSTRIES.map((p) => ( {INDUSTRIES.map((p) => (
<RevealItem as="li" className="proof__item" key={p}> <RevealItem as="li" className="proof__item" key={p}>
<span className="proof__item-dot" aria-hidden="true" />
{p} {p}
</RevealItem> </RevealItem>
))} ))}
@ -88,6 +108,8 @@ export default function Page() {
</div> </div>
</section> </section>
<SectionDivider label="What we do" />
{/* ===== SERVICES — interactive ledger ===== */} {/* ===== SERVICES — interactive ledger ===== */}
<section id="services" className="services frame" aria-labelledby="svc-h"> <section id="services" className="services frame" aria-labelledby="svc-h">
<div className="wrap"> <div className="wrap">
@ -96,9 +118,11 @@ export default function Page() {
<span className="kicker__dot" /> <span className="kicker__dot" />
Services / 06 Services / 06
</p> </p>
<h2 id="svc-h" className="display sec-head__title"> <Reveal as="h2" variant="clip">
How we grow your business <span id="svc-h" className="display sec-head__title">
</h2> How we grow your business
</span>
</Reveal>
</header> </header>
<Reveal as="ol" className="ledger" stagger={0.07}> <Reveal as="ol" className="ledger" stagger={0.07}>
@ -129,9 +153,11 @@ export default function Page() {
<span className="kicker__dot" /> <span className="kicker__dot" />
Selected work sample Selected work sample
</p> </p>
<h2 id="work-h" className="display sec-head__title"> <Reveal as="h2" variant="clip">
Proof, not promises <span id="work-h" className="display sec-head__title">
</h2> Proof, not promises
</span>
</Reveal>
</header> </header>
<div className="work__grid"> <div className="work__grid">
@ -155,6 +181,19 @@ export default function Page() {
{/* ===== TESTIMONIALS ===== */} {/* ===== TESTIMONIALS ===== */}
<section className="quotes frame" aria-label="What clients say"> <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"> <div className="wrap quotes__grid">
{testimonials.map((t, i) => ( {testimonials.map((t, i) => (
<Reveal as="figure" className="quote" key={i} delay={i * 120}> <Reveal as="figure" className="quote" key={i} delay={i * 120}>
@ -184,9 +223,11 @@ export default function Page() {
<span className="kicker__dot" /> <span className="kicker__dot" />
FAQ FAQ
</p> </p>
<h2 id="faq-h" className="display sec-head__title"> <Reveal as="h2" variant="clip">
Questions about working with a digital marketing agency <span id="faq-h" className="display sec-head__title">
</h2> Questions about working with a digital marketing agency
</span>
</Reveal>
</header> </header>
<Faq /> <Faq />
</div> </div>