feat: craft pass (Emil design-eng) — easing system, momentum springs, press states, clip-path reveals, alive motion
This commit is contained in:
parent
80f46b9780
commit
d5ad025607
14 changed files with 979 additions and 235 deletions
|
|
@ -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.
|
||||||
|
* - 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.
|
* 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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,11 +46,26 @@ 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;
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (video && !reduce) {
|
let io: IntersectionObserver | null = null;
|
||||||
const io = new IntersectionObserver(
|
let onVis: (() => void) | null = null;
|
||||||
|
|
||||||
|
if (!reduce) {
|
||||||
|
if (video) {
|
||||||
|
io = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (entry.isIntersecting) video.play().catch(() => {});
|
if (entry.isIntersecting) video.play().catch(() => {});
|
||||||
else video.pause();
|
else video.pause();
|
||||||
|
|
@ -46,80 +73,111 @@ export default function Hero() {
|
||||||
{ threshold: 0.1 }
|
{ threshold: 0.1 }
|
||||||
);
|
);
|
||||||
io.observe(video);
|
io.observe(video);
|
||||||
const onVis = () => {
|
onVis = () => {
|
||||||
if (document.hidden) video.pause();
|
if (document.hidden) video.pause();
|
||||||
else if (video.getBoundingClientRect().top < window.innerHeight) video.play().catch(() => {});
|
else if (video.getBoundingClientRect().top < window.innerHeight)
|
||||||
|
video.play().catch(() => {});
|
||||||
};
|
};
|
||||||
document.addEventListener("visibilitychange", onVis);
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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" }}
|
||||||
>
|
>
|
||||||
|
<motion.span style={{ x: innerX, y: innerY, display: "inline-block" }}>
|
||||||
{children}
|
{children}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
|
</motion.span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +98,8 @@ 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">
|
||||||
|
<div>
|
||||||
<p className="kicker">
|
<p className="kicker">
|
||||||
<span className="kicker__dot" />
|
<span className="kicker__dot" />
|
||||||
The Feedback Loop
|
The Feedback Loop
|
||||||
|
|
@ -91,6 +107,12 @@ export default function ProcessLoop() {
|
||||||
<h2 id="loop-h" className="display sec-head__title">
|
<h2 id="loop-h" className="display sec-head__title">
|
||||||
How it works
|
How it works
|
||||||
</h2>
|
</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>
|
||||||
</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) => (
|
||||||
|
|
|
||||||
|
|
@ -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 (30–80ms).
|
||||||
|
* - 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}>
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,37 @@ 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
|
||||||
|
// 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,
|
scaleY: 0,
|
||||||
transformOrigin: "bottom",
|
transformOrigin: "bottom",
|
||||||
duration: 0.9,
|
duration: 1,
|
||||||
ease: "expo.out",
|
ease: "back.out(1.4)",
|
||||||
stagger: 0.08,
|
stagger: 0.07,
|
||||||
}).from(
|
},
|
||||||
".score__baseline",
|
"-=0.45"
|
||||||
{ drawSVG: "0%", duration: 0.9, ease: "power2.out" },
|
)
|
||||||
"-=0.7"
|
.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);
|
||||||
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
74
app/components/SectionDivider.tsx
Normal file
74
app/components/SectionDivider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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) => {
|
||||||
|
const isActive = active === n.href;
|
||||||
|
return (
|
||||||
<li key={n.href}>
|
<li key={n.href}>
|
||||||
<a href={n.href} data-cursor="">
|
<a
|
||||||
|
href={n.href}
|
||||||
|
className={isActive ? "is-active" : ""}
|
||||||
|
aria-current={isActive ? "true" : undefined}
|
||||||
|
>
|
||||||
{n.label}
|
{n.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
|
||||||
38
app/components/motion.ts
Normal file
38
app/components/motion.ts
Normal 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,
|
||||||
|
});
|
||||||
447
app/globals.css
447
app/globals.css
|
|
@ -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 */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.btn--accent:hover {
|
||||||
background-position: 100% 50%;
|
background-position: 100% 50%;
|
||||||
box-shadow: 0 14px 40px -8px rgba(139, 92, 246, 0.7);
|
box-shadow: 0 16px 44px -8px rgba(139, 92, 246, 0.7);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn--accent:hover::after { transform: translateX(120%); }
|
||||||
}
|
}
|
||||||
.btn--accent:active { transform: scale(0.97); }
|
/* 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) {
|
||||||
|
.btn--ghost:hover {
|
||||||
border-color: var(--violet);
|
border-color: var(--violet);
|
||||||
background: rgba(139, 92, 246, 0.1);
|
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 {
|
||||||
|
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);
|
color: var(--c-text);
|
||||||
border-color: var(--violet);
|
border-color: var(--violet);
|
||||||
background: rgba(139, 92, 246, 0.08);
|
background: rgba(139, 92, 246, 0.08);
|
||||||
transform: translateY(-2px);
|
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) {
|
||||||
|
.ledger__row:hover .ledger__no { color: var(--emerald); }
|
||||||
|
.ledger__row:hover .ledger__name {
|
||||||
color: transparent;
|
color: transparent;
|
||||||
background: var(--grad-text);
|
background: var(--grad-text);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
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) {
|
||||||
|
.case:hover .case__inner {
|
||||||
border-color: color-mix(in srgb, var(--case-accent) 55%, transparent);
|
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);
|
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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
app/page.tsx
53
app/page.tsx
|
|
@ -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">
|
||||||
|
<span id="svc-h" className="display sec-head__title">
|
||||||
How we grow your business
|
How we grow your business
|
||||||
</h2>
|
</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">
|
||||||
|
<span id="work-h" className="display sec-head__title">
|
||||||
Proof, not promises
|
Proof, not promises
|
||||||
</h2>
|
</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">
|
||||||
|
<span id="faq-h" className="display sec-head__title">
|
||||||
Questions about working with a digital marketing agency
|
Questions about working with a digital marketing agency
|
||||||
</h2>
|
</span>
|
||||||
|
</Reveal>
|
||||||
</header>
|
</header>
|
||||||
<Faq />
|
<Faq />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue