"use client"; import { ArrowRight, CirclePlay, RotateCcw } from "lucide-react"; import { animate, motion, useInView, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { Fragment, type KeyboardEvent, type PointerEvent, type ReactNode, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; const headlineType = "font-heading font-semibold text-[42px] leading-[0.98] tracking-[-0.045em] sm:text-6xl sm:leading-[0.98] lg:text-[88px] lg:leading-[0.95]"; const gravity = 3400; const wordGap = 0.085; const swell = 0.14; const neck = 0.36; const formFor = 0.84; const fall = [0.11, 0, 0.5, 0] as const; const settle = [0.2, 0.8, 0.4, 1] as const; const stretch = [0.45, 0, 0.55, 1] as const; const pressSpring = { stiffness: 650, damping: 22, mass: 1 }; type Phase = "waiting" | "clearing" | "pouring" | "settled"; type WordState = "wet" | "set"; interface Box { x: number; y: number; width: number; height: number; } interface Action { label: string; href: string; } function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } function offsetWithin(element: HTMLElement, root: HTMLElement): Box { let x = 0; let y = 0; let node: HTMLElement | null = element; while (node && node !== root) { x += node.offsetLeft; y += node.offsetTop; node = node.offsetParent as HTMLElement | null; } return { x, y, width: element.offsetWidth, height: element.offsetHeight }; } function gooMatrix(slope: number, offset: number) { return `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${slope} ${offset}`; } function at(start: number, total: number, time: number) { return Math.min(Math.max((time - start) / total, 0), 1); } function Drip({ x, start, land, landY, radius, bead, }: { x: number; start: number; land: number; landY: number; radius: number; bead: number; }) { const hang = bead * 0.15; const neckY = hang + bead + radius * 1.4; const total = land + 0.24 - start; const times = [0, at(start, total, start + swell), at(start, total, start + neck), at(start, total, land), at(start, total, land + 0.08), 1]; const tailTotal = land + 0.14 - start; const tailTimes = [0, at(start, tailTotal, start + swell), at(start, tailTotal, start + neck + 0.02), at(start, tailTotal, land + 0.05), 1]; const tail = radius * 0.5; return ( <> > ); } function Splash({ x, y, land, radius, reach, rise, floor, }: { x: number; y: number; land: number; radius: number; reach: number[]; rise: number; floor: number; }) { const puddle = { width: radius * 3.4, height: radius * 1.5 }; return ( <> {reach.map((to) => { const size = radius * 0.55; return ( ); })} > ); } function PouringWord({ word, box, em, delay, onFormed, typeClass, }: { word: string; box: Box; em: number; delay: number; onFormed: () => void; typeClass: string; }) { const filterId = `pour-word-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`; const blurRef = useRef(null); const matrixRef = useRef(null); const formed = useRef(onFormed); formed.current = onFormed; const radius = Math.max(em * 0.1, 4.5); const bead = radius * 1.35; const goo = radius * 0.6; const padX = Math.max(em * 0.6, radius * 6); const { x, y, width, height } = box; const landY = y + height * 0.58; const floor = y + height * 0.5; const spots = word.length > 5 ? [0.3, 0.72] : [0.5]; const drops = spots.map((spot, index) => { const start = delay + index * 0.07; const from = bead * 1.15 + radius * 1.4; return { x: padX + width * spot, start, land: start + neck + Math.sqrt((2 * Math.max(landY - from, 1)) / gravity), }; }); const landed = drops[0].land; const clampX = (value: number) => Math.min(Math.max(value, padX + radius), padX + width - radius); useEffect(() => { const controls = animate(0, 1, { delay: landed + 0.12, duration: 0.7, ease: [0.5, 0, 0.2, 1], onUpdate: (progress) => { const fade = Math.max(0, (progress - 0.6) / 0.4); blurRef.current?.setAttribute("stdDeviation", String(goo * (1 - progress))); matrixRef.current?.setAttribute("values", gooMatrix(20 - 19 * fade, -9 * (1 - fade))); }, }); const timer = setTimeout(() => formed.current(), (landed + formFor) * 1000); return () => { controls.stop(); clearTimeout(timer); }; }, [goo, landed]); return ( {drops.map((drop) => ( ))} {word} {drops.map((drop) => ( ))} ); } interface Droplet { id: number; x: number; } function SplashDrop({ from, to, rise, radius, duration, onLand, }: { from: number; to: number; rise: number; radius: number; duration: number; onLand?: () => void; }) { const sunk = radius + 2; return ( ); } function PourButton({ href, children, reduce }: { href: string; children: ReactNode; reduce: boolean }) { const filterId = `pour-button-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`; const bodyRef = useRef(null); const [pressed, setPressed] = useState(false); const [droplets, setDroplets] = useState([]); const nextId = useRef(0); const holding = useRef(false); const where = useRef(0.5); const tapTimer = useRef>(undefined); const scaleX = useSpring(1, pressSpring); const squash = useSpring(1, pressSpring); const ripple = useMotionValue(1); const scaleY = useTransform(() => squash.get() * ripple.get()); const originX = useMotionValue(0.5); useEffect(() => () => clearTimeout(tapTimer.current), []); const squeeze = (fraction: number) => { setPressed(true); where.current = Math.min(Math.max(fraction, 0.22), 0.78); originX.set(where.current); if (reduce) return; scaleX.set(1.045); squash.set(0.84); }; const letGo = (splash: boolean) => { setPressed(false); scaleX.set(1); squash.set(1); if (reduce || !splash) return; const width = bodyRef.current?.offsetWidth ?? 0; const id = nextId.current++; setDroplets((current) => [...current.slice(-2), { id, x: where.current * width }]); }; const land = (id: number) => { setDroplets((current) => current.filter((droplet) => droplet.id !== id)); animate(ripple, [1, 0.95, 1.015, 1], { duration: 0.32, ease: "easeOut" }); }; const fractionOf = (event: PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); return rect.width ? (event.clientX - rect.left) / rect.width : 0.5; }; const room = { x: 30, top: 44, bottom: 10 }; const width = bodyRef.current?.offsetWidth ?? 0; const edge = 22; const clamp = (value: number) => (width ? Math.min(Math.max(value, edge), width - edge) : value); return ( { if (holding.current) { holding.current = false; letGo(false); } }} onKeyDown={(event: KeyboardEvent) => { if (event.key !== "Enter" || event.repeat) return; clearTimeout(tapTimer.current); squeeze(0.5); tapTimer.current = setTimeout(() => letGo(true), 80); }} onPointerCancel={() => { holding.current = false; letGo(false); }} onPointerDown={(event) => { if (event.button !== 0) return; holding.current = true; squeeze(fractionOf(event)); }} onPointerLeave={() => { if (!holding.current) return; holding.current = false; letGo(false); }} onPointerUp={() => { if (!holding.current) return; holding.current = false; letGo(true); }} > {droplets.map((droplet) => ( land(droplet.id)} radius={6} rise={20} to={droplet.x + (width / 2 - droplet.x) * 0.06} /> ))} {children} ); } const headroom = 128; function GooCursor({ lines, typeClass, x, y, active }: { lines: string[][]; typeClass: string; x: ReturnType>; y: ReturnType>; active: boolean }) { const filterId = `pour-cursor-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`; const blobX = useSpring(x, { stiffness: 380, damping: 28 }); const blobY = useSpring(y, { stiffness: 380, damping: 28 }); const tailX = useSpring(x, { stiffness: 120, damping: 18 }); const tailY = useSpring(y, { stiffness: 120, damping: 18 }); const lagX = useSpring(x, { stiffness: 60, damping: 14 }); const lagY = useSpring(y, { stiffness: 60, damping: 14 }); const mask = useMotionTemplate`radial-gradient(circle 8rem at ${blobX}px ${blobY}px, #000 58%, transparent 74%)`; return ( {lines.map((line, index) => ( {line.join(" ")} ))} ); } export interface PourHeadlineProps { lines: string[]; className?: string; replay?: number; playful?: boolean; } export function PourHeadline({ lines: headline, className, replay = 0, playful = false }: PourHeadlineProps) { const reduce = useSafeReducedMotion(); const rootRef = useRef(null); const wordRefs = useRef<(HTMLSpanElement | null)[]>([]); const inView = useInView(rootRef, { once: true, amount: 0.3 }); const [phase, setPhase] = useState("waiting"); const [run, setRun] = useState(0); const [boxes, setBoxes] = useState([]); const [em, setEm] = useState(0); const [states, setStates] = useState([]); const timers = useRef[]>([]); const typeClass = cn(headlineType, className); const pointerX = useMotionValue(0); const pointerY = useMotionValue(0); const [hovering, setHovering] = useState(false); const [finePointer, setFinePointer] = useState(false); useEffect(() => setFinePointer(window.matchMedia("(hover: hover) and (pointer: fine)").matches), []); const toy = playful && finePointer && !reduce; const lines = headline.map((line) => line.split(/\s+/).filter(Boolean)); const words = lines.flat(); const settledCount = states.filter((state) => state === "set").length; const clearTimers = useCallback(() => { for (const timer of timers.current) clearTimeout(timer); timers.current = []; }, []); const later = useCallback((callback: () => void, ms: number) => { timers.current.push(setTimeout(callback, ms)); }, []); const pour = useCallback(() => { clearTimers(); setStates([]); setBoxes([]); setRun((current) => current + 1); setPhase("pouring"); }, [clearTimers]); const again = useRef(() => {}); again.current = () => { clearTimers(); if (phase === "settled") { setPhase("clearing"); later(pour, 180); } else { pour(); } }; useEffect(() => clearTimers, [clearTimers]); useEffect(() => { if (replay > 0) again.current(); }, [replay]); useEffect(() => { if (!reduce) return; clearTimers(); setPhase("settled"); }, [reduce, clearTimers]); useEffect(() => { if (inView && !reduce && phase === "waiting") pour(); }, [inView, reduce, phase, pour]); useLayoutEffect(() => { if (phase !== "pouring" || run === 0) return; const root = rootRef.current; const first = wordRefs.current[0]; if (!root || !first) return; setEm(Number.parseFloat(getComputedStyle(first).fontSize) || 64); setBoxes( wordRefs.current.slice(0, words.length).map((element) => { const box = element ? offsetWithin(element, root) : { x: 0, y: 0, width: 0, height: 0 }; return { ...box, y: box.y + headroom }; }), ); }, [phase, run, words.length]); useEffect(() => { if (phase !== "pouring" || words.length === 0 || settledCount < words.length) return; const timer = setTimeout(() => setPhase("settled"), 650); return () => clearTimeout(timer); }, [phase, settledCount, words.length]); const mark = (index: number, state: WordState) => setStates((current) => { const next = [...current]; next[index] = state; return next; }); const formWord = (index: number) => { mark(index, "wet"); later(() => mark(index, "set"), 160); }; const wordTone = (index: number) => { if (phase === "waiting") return "motion-safe:text-transparent"; if (phase === "clearing") return undefined; const state = states[index]; if (state === "set") return "text-foreground transition-colors duration-500 ease-out"; if (state === "wet") return "text-(--liquid)"; return "text-transparent"; }; let wordIndex = 0; return ( again.current() : undefined} onPointerLeave={toy ? () => setHovering(false) : undefined} onPointerMove={ toy ? (event: PointerEvent) => { const box = event.currentTarget.getBoundingClientRect(); if (!hovering) { pointerX.jump(event.clientX - box.left); pointerY.jump(event.clientY - box.top); } pointerX.set(event.clientX - box.left); pointerY.set(event.clientY - box.top); setHovering(true); } : undefined } ref={rootRef} > {lines.map((line, lineIndex) => ( {phase === "settled" ? line.join(" ") : line.map((word, position) => { const index = wordIndex++; return ( {position > 0 && " "} { wordRefs.current[index] = element; }} > {word} ); })} ))} {toy && phase === "settled" && } {phase === "pouring" && em > 0 && boxes.length === words.length && ( {boxes.map((box, index) => states[index] ? null : ( formWord(index)} typeClass={typeClass} word={words[index]} /> ), )} )} ); } export interface PourHeroProps { headline: string[]; description: string; primaryAction: Action; secondaryAction?: Action; className?: string; } export function PourHero({ headline, description, primaryAction, secondaryAction, className }: PourHeroProps) { const reduce = useSafeReducedMotion(); const [replays, setReplays] = useState(0); return ( {description} {primaryAction.label} {secondaryAction && ( {secondaryAction.label} )} {!reduce && ( setReplays((count) => count + 1)} type="button" > Replay )} ); }
{description}