"use client"; import { type AnimationPlaybackControls, animate, type MotionValue, motion, useInView, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type LaunchPadHue = "sky" | "emerald" | "amber" | "violet" | "rose" | "orange" | "indigo" | "teal"; const hues: Record = { sky: "[--hue:var(--color-sky-500)] [--hue-soft:var(--color-sky-200)] [--hue-deep:var(--color-sky-700)]", emerald: "[--hue:var(--color-emerald-500)] [--hue-soft:var(--color-emerald-200)] [--hue-deep:var(--color-emerald-700)]", amber: "[--hue:var(--color-amber-500)] [--hue-soft:var(--color-amber-200)] [--hue-deep:var(--color-amber-700)]", violet: "[--hue:var(--color-violet-500)] [--hue-soft:var(--color-violet-200)] [--hue-deep:var(--color-violet-700)]", rose: "[--hue:var(--color-rose-500)] [--hue-soft:var(--color-rose-200)] [--hue-deep:var(--color-rose-700)]", orange: "[--hue:var(--color-orange-500)] [--hue-soft:var(--color-orange-200)] [--hue-deep:var(--color-orange-700)]", indigo: "[--hue:var(--color-indigo-500)] [--hue-soft:var(--color-indigo-200)] [--hue-deep:var(--color-indigo-700)]", teal: "[--hue:var(--color-teal-500)] [--hue-soft:var(--color-teal-200)] [--hue-deep:var(--color-teal-700)]", }; type Phase = "ready" | "ignition" | "liftoff" | "landing" | "landed"; const phaseCopy: Record = { ready: "Ready", ignition: "Ignition", liftoff: "Liftoff", landing: "Landing", landed: "Landed", }; export interface LaunchPadProps { hue?: LaunchPadHue; name?: string; onLaunch?: () => void; onLanded?: () => void; className?: string; } const CX = 220; const PAD_Y = 236; const GROUND_Y = 262; const FLIGHT = 360; const smoke = [ { dx: -1, spread: 74, lift: 14, r: 15, delay: 0 }, { dx: 1, spread: 78, lift: 12, r: 16, delay: 0.02 }, { dx: -1, spread: 42, lift: 22, r: 19, delay: 0.05 }, { dx: 1, spread: 46, lift: 20, r: 18, delay: 0.06 }, { dx: -1, spread: 14, lift: 8, r: 20, delay: 0.08 }, { dx: 1, spread: 16, lift: 10, r: 19, delay: 0.1 }, { dx: -1, spread: 58, lift: 18, r: 17, delay: 0.03 }, { dx: 1, spread: 30, lift: 16, r: 17, delay: 0.07 }, ]; const steam = [ { x: CX - 46, delay: 0 }, { x: CX - 46, delay: 1.4 }, { x: CX + 46, delay: 0.7 }, { x: CX + 46, delay: 2.1 }, ]; function SmokeCloud({ progress, size, dx, spread, lift, r, delay, }: (typeof smoke)[number] & { progress: MotionValue; size: MotionValue }) { const local = useTransform(progress, (p) => Math.min(1, Math.max(0, (p - delay) / (1 - delay)))); const x = useTransform([local, size] as const, ([p, s]: number[]) => CX + dx * spread * s * (1 - (1 - p) ** 3)); const y = useTransform([local, size] as const, ([p, s]: number[]) => GROUND_Y - 6 - lift * s * p); // Fades by shrinking, not opacity, so the goo threshold pinches clouds off cleanly instead of fringing. const scale = useTransform([local, size] as const, ([p, s]: number[]) => { const grow = 0.4 + p * 0.8; const fade = p < 0.06 ? p / 0.06 : p > 0.7 ? Math.max(0, 1 - (p - 0.7) / 0.3) : 1; return grow * fade * (0.55 + s * 0.45); }); return ; } function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export function LaunchPad({ hue = "sky", name = "Kestrel-1", onLaunch, onLanded, className }: LaunchPadProps) { const uid = useId().replace(/:/g, ""); const reduce = useCalmMotion(); const root = useRef(null); const inView = useInView(root); const live = inView && !reduce; const [phase, setPhase] = useState("ready"); const y = useMotionValue(0); const shake = useMotionValue(0); const flame = useMotionValue(0); const arm = useMotionValue(0); const puff = useMotionValue(0); const puffSize = useMotionValue(1); const trailOpacity = useMotionValue(0); const trailSpread = useMotionValue(1); const squash = useMotionValue(1); const trailScale = useTransform(y, (v) => Math.min(1, Math.max(0, -v / FLIGHT))); const flameOpacity = useTransform(flame, [0, 0.15, 1], [0, 1, 1]); const smokeOpacity = useTransform(puff, [0, 0.45, 0.95], [1, 1, 0]); const glow = useTransform([flame, y] as const, ([f, v]: number[]) => f * Math.max(0, 1 + v / 110)); const busy = phase !== "ready"; const run = useRef(0); const timers = useRef(new Set>()); const controls = useRef([]); useEffect(() => { const pending = timers.current; const running = controls.current; return () => { run.current++; for (const t of pending) clearTimeout(t); for (const c of running) c.stop(); }; }, []); const wait = (ms: number) => new Promise((resolve) => { const t = setTimeout(() => { timers.current.delete(t); resolve(); }, ms); timers.current.add(t); }); const play = (c: AnimationPlaybackControls) => { controls.current.push(c); return c; }; const launch = async () => { if (busy) return; const id = ++run.current; const alive = () => run.current === id; controls.current = []; onLaunch?.(); setPhase("ignition"); if (reduce) { await wait(500); if (!alive()) return; setPhase("liftoff"); await wait(900); if (!alive()) return; setPhase("landed"); onLanded?.(); await wait(1400); if (alive()) setPhase("ready"); return; } play(animate(arm, -42, { duration: 0.4, ease: [0.23, 1, 0.32, 1] })); play(animate(flame, 1, { duration: 0.3, ease: "easeOut" })); play(animate(shake, [0, -1.2, 1.2, -1, 1, -0.8, 0.8, -0.4, 0], { duration: 0.65, ease: "linear" })); puffSize.set(1); puff.set(0); play(animate(puff, 1, { duration: 2.4, ease: [0.2, 0.7, 0.3, 1] })); trailOpacity.set(1); trailSpread.set(1); await wait(600); if (!alive()) return; setPhase("liftoff"); await play(animate(y, -FLIGHT - 60, { duration: 1.45, ease: [0.6, 0, 0.9, 0.35] })); if (!alive()) return; play(animate(trailOpacity, 0, { duration: 1.1, ease: "easeOut" })); play(animate(trailSpread, 2.2, { duration: 1.1, ease: "easeOut" })); await wait(700); if (!alive()) return; setPhase("landing"); const down = play(animate(y, 0, { duration: 1.7, ease: [0.15, 0.6, 0.25, 1] })); const throttle = setTimeout(() => play(animate(flame, 0.55, { duration: 0.5 })), 900); timers.current.add(throttle); await down; if (!alive()) return; play(animate(flame, 0, { duration: 0.2, ease: "easeOut" })); play(animate(squash, [1, 0.94, 1], { duration: 0.35, ease: [0.23, 1, 0.32, 1] })); puffSize.set(0.45); puff.set(0); play(animate(puff, 1, { duration: 1.4, ease: [0.2, 0.7, 0.3, 1] })); setPhase("landed"); onLanded?.(); await wait(450); if (!alive()) return; play(animate(arm, 0, { duration: 0.6, ease: [0.65, 0, 0.35, 1] })); await wait(1100); if (alive()) setPhase("ready"); }; const statusTone = phase === "ready" || phase === "landed" ? "bg-success" : phase === "ignition" ? "bg-warning" : "bg-(--hue)"; return (
{phase === "ignition" && !reduce && ( )} {name} {phaseCopy[phase]}
Click to launch
); }