β‘ the promise
diff --git a/src/components/story/ui.tsx b/src/components/story/ui.tsx
index 0f52a81..021efae 100644
--- a/src/components/story/ui.tsx
+++ b/src/components/story/ui.tsx
@@ -1,14 +1,32 @@
"use client";
-import { ReactNode } from "react";
+import { ReactNode, useEffect, useRef, useSyncExternalStore } from "react";
import Image from "next/image";
-import { motion, Variants } from "framer-motion";
+import { motion, useSpring, Variants } from "framer-motion";
import { C } from "./palette";
export { C };
const viewport = { once: true, amount: 0.2 } as const;
+/* Respect the OS "reduce motion" setting β entrances collapse to a plain fade
+ so the page stays usable for anyone who gets motion sickness. */
+const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";
+
+function subscribeReduced(onChange: () => void) {
+ const mql = window.matchMedia(REDUCE_QUERY);
+ mql.addEventListener("change", onChange);
+ return () => mql.removeEventListener("change", onChange);
+}
+
+export function useReducedMotion() {
+ return useSyncExternalStore(
+ subscribeReduced,
+ () => window.matchMedia(REDUCE_QUERY).matches,
+ () => false, // server render: assume motion is fine, corrected on hydrate
+ );
+}
+
/* Shared smooth easing β a soft, premium ease-out curve reused across reveals. */
const smoothEase = [0.22, 1, 0.36, 1] as const;
@@ -33,9 +51,49 @@ type SectionProps = {
children: ReactNode;
variant?: "cream" | "deep" | "night" | "plain";
className?: string;
+ /** optional full-bleed background image layered over the variant colour */
+ bgImage?: string;
+ /** Vertical weight. Drives the page's pacing β not every beat deserves a
+ full screen. `punch` is a short interstitial, `epic` is an over-tall
+ statement moment. */
+ size?: "punch" | "normal" | "full" | "epic";
+ /** Content width. `bleed` lets a section run edge-to-edge instead of sitting
+ in the same 6xl column as everything else. */
+ width?: "narrow" | "default" | "wide" | "bleed";
+ /** Vertical anchoring of the content within the section. */
+ align?: "center" | "start" | "end";
};
-export function Section({ id, children, variant = "deep", className = "" }: SectionProps) {
+const SIZE = {
+ punch: "min-h-[42vh] py-14",
+ normal: "min-h-[70vh] py-16",
+ full: "min-h-[calc(100vh-40px)] py-20",
+ epic: "min-h-[125vh] py-28",
+} as const;
+
+const WIDTH = {
+ narrow: "max-w-3xl",
+ default: "max-w-6xl",
+ wide: "max-w-[1600px]",
+ bleed: "max-w-none",
+} as const;
+
+const ALIGN = {
+ center: "justify-center",
+ start: "justify-start",
+ end: "justify-end",
+} as const;
+
+export function Section({
+ id,
+ children,
+ variant = "deep",
+ className = "",
+ bgImage,
+ size = "full",
+ width = "default",
+ align = "center",
+}: SectionProps) {
const bg =
variant === "cream"
? "bg-cream text-[#0a0a0a]"
@@ -47,9 +105,29 @@ export function Section({ id, children, variant = "deep", className = "" }: Sect
return (
- {children}
+ {bgImage && (
+
+ )}
+ {/* Scrim β keeps copy legible over the busy artwork. Darkest through the
+ centre column where the text sits, lighter at the edges so the props
+ stay visible. */}
+ {bgImage && (
+
+ )}
+ {children}
);
}
@@ -79,6 +157,266 @@ export function Reveal({
);
}
+/* ------- useScrollPass -------
+ Calls back with 0..1 as an element travels from entering the bottom of the
+ viewport to leaving the top.
+
+ Deliberately NOT framer-motion's useScroll: this app drives scrolling with
+ Lenis, and useScroll's progress stays pinned at 0 the whole way through
+ (verified β a Parallax on it never left its initial offset). A rAF loop is
+ agnostic to how scroll is driven. Gated on an IntersectionObserver so the
+ loop only spins while the element is actually near the viewport. */
+function useScrollPass(
+ ref: React.RefObject
,
+ onProgress: (p: number) => void,
+ enabled = true,
+) {
+ const cb = useRef(onProgress);
+ useEffect(() => {
+ cb.current = onProgress;
+ }, [onProgress]);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el || !enabled) return;
+ let frame = 0;
+ let last = -1;
+
+ const paint = () => {
+ const r = el.getBoundingClientRect();
+ const span = window.innerHeight + r.height;
+ const p = span > 0 ? (window.innerHeight - r.top) / span : 0;
+ const clamped = Math.min(1, Math.max(0, p));
+ if (clamped !== last) {
+ last = clamped;
+ cb.current(clamped);
+ }
+ frame = requestAnimationFrame(paint);
+ };
+
+ const io = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting && !frame) frame = requestAnimationFrame(paint);
+ else if (!entry.isIntersecting && frame) {
+ cancelAnimationFrame(frame);
+ frame = 0;
+ }
+ },
+ { rootMargin: "200px 0px" },
+ );
+ io.observe(el);
+ return () => {
+ io.disconnect();
+ if (frame) cancelAnimationFrame(frame);
+ };
+ }, [ref, enabled]);
+}
+
+/* ------- Parallax (scroll-linked drift) -------
+ Unlike Reveal, this stays tied to scroll position the whole time the element
+ is on screen, so the page has depth rather than just fading things in.
+ `speed` is how far it travels, in px, across the full pass. */
+export function Parallax({
+ children,
+ className = "",
+ speed = 80,
+}: {
+ children: ReactNode;
+ className?: string;
+ speed?: number;
+}) {
+ const outer = useRef(null);
+ const inner = useRef(null);
+ const reduced = useReducedMotion();
+
+ useScrollPass(
+ outer,
+ (p) => {
+ const el = inner.current;
+ if (el) el.style.transform = `translate3d(0, ${(0.5 - p) * 2 * speed}px, 0)`;
+ },
+ !reduced,
+ );
+
+ return (
+
+ );
+}
+
+/* ------- Marquee (continuous horizontal scroll) -------
+ A funky, always-moving band. Doubles its children so the loop is seamless. */
+export function Marquee({
+ children,
+ className = "",
+ duration = 22,
+ reverse = false,
+}: {
+ children: ReactNode;
+ className?: string;
+ duration?: number;
+ reverse?: boolean;
+}) {
+ return (
+
+
+ {/* Rendered twice β the second copy is what the loop scrolls into. */}
+ {children}
+
+ {children}
+
+
+
+ );
+}
+
+/* ------- SplitWords -------
+ Headline that assembles word by word, each one rising and unblurring from
+ its own mask. The mask is what sells it β words emerge from behind a hard
+ edge rather than just fading. */
+export function SplitWords({
+ text,
+ className = "",
+ delay = 0,
+ stagger = 0.055,
+}: {
+ text: string;
+ className?: string;
+ delay?: number;
+ stagger?: number;
+}) {
+ const reduced = useReducedMotion();
+ const words = text.split(" ");
+ return (
+
+ {words.map((w, i) => (
+ // Real space between words so the text stays one readable string for
+ // screen readers, copy-paste and crawlers β the mask is presentation.
+
+
+
+ {w}
+
+
+ {i < words.length - 1 ? " " : null}
+
+ ))}
+
+ );
+}
+
+/* ------- ScrollLitText -------
+ Long statement where each word lights up as it passes the middle of the
+ viewport β the copy reads itself as you scroll. */
+export function ScrollLitText({
+ text,
+ className = "",
+ dim = "rgba(255,255,255,0.16)",
+ lit = "#ffffff",
+}: {
+ text: string;
+ className?: string;
+ dim?: string;
+ lit?: string;
+}) {
+ const ref = useRef(null);
+ const reduced = useReducedMotion();
+ const words = text.split(" ");
+
+ /* The pass is remapped so the line is fully lit by the time it reaches the
+ middle of the screen, rather than only at the very top. */
+ useScrollPass(
+ ref,
+ (p) => {
+ const el = ref.current;
+ if (!el) return;
+ const spans = el.querySelectorAll("[data-lit-word]");
+ const eased = Math.min(1, Math.max(0, (p - 0.15) / 0.45));
+ spans.forEach((s, i) => {
+ const t = Math.min(1, Math.max(0, eased * spans.length - i));
+ s.style.color = t > 0.5 ? lit : dim;
+ s.style.opacity = String(0.35 + t * 0.65);
+ });
+ },
+ !reduced,
+ );
+
+ return (
+
+ {words.map((w, i) => (
+
+
+ {w}
+
+ {i < words.length - 1 ? " " : null}
+
+ ))}
+
+ );
+}
+
+/* ------- Magnetic -------
+ Pulls toward the cursor on hover and springs back on leave. Pointer-driven,
+ so it's skipped on touch and under reduced-motion. */
+export function Magnetic({
+ children,
+ className = "",
+ strength = 0.35,
+}: {
+ children: ReactNode;
+ className?: string;
+ strength?: number;
+}) {
+ const ref = useRef(null);
+ const reduced = useReducedMotion();
+ const x = useSpring(0, { stiffness: 260, damping: 18 });
+ const y = useSpring(0, { stiffness: 260, damping: 18 });
+
+ const onMove = (e: React.PointerEvent) => {
+ if (reduced || e.pointerType !== "mouse" || !ref.current) return;
+ const r = ref.current.getBoundingClientRect();
+ x.set((e.clientX - (r.left + r.width / 2)) * strength);
+ y.set((e.clientY - (r.top + r.height / 2)) * strength);
+ };
+ const reset = () => {
+ x.set(0);
+ y.set(0);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
/* ------- Group (stagger children that use riseItem) ------- */
export function Group({
children,