"use client"; import { motion, useReducedMotion } from "motion/react"; import { useEffect, useId, useState } from "react"; import { EASE_IN_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type LoaderVariant = | "spinner" | "dots" | "bars" | "dot-matrix" | "dither" | "ascii" | "ascii-line" | "ascii-braille" | "ascii-blocks" | "ascii-bounce" | "morph" | "comet" | "scramble" | "metaballs" | "newton" | "helix" | "percent"; // Terminal-style frame sets — the loaders CLI AI agents cycle through. const ASCII_SETS: Record = { ascii: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], "ascii-line": ["|", "/", "-", "\\"], "ascii-braille": ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"], "ascii-blocks": ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"], "ascii-bounce": ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"], }; export interface LoaderProps { /** Which animation to render. */ variant?: LoaderVariant; /** Base square size in px. Everything scales from this. */ size?: number; /** Seconds per animation cycle. */ speed?: number; /** Accessible label announced to screen readers. */ label?: string; className?: string; } // Reduced motion keeps a calm opacity pulse and drops every transform. const REDUCED = { animate: { opacity: [1, 0.4, 1] }, transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }, }; export function Loader({ variant = "spinner", size = 32, speed = 1, label = "Loading", className, }: LoaderProps) { const reduce = useReducedMotion() ?? false; return ( {variant === "spinner" && } {variant === "dots" && } {variant === "bars" && } {variant === "dot-matrix" && ( )} {variant === "dither" && } {ASCII_SETS[variant] && ( )} {variant === "morph" && } {variant === "comet" && } {variant === "scramble" && ( )} {variant === "metaballs" && ( )} {variant === "newton" && } {variant === "helix" && } {variant === "percent" && ( )} {label} ); } interface PartProps { size: number; speed: number; reduce: boolean; } function Spinner({ size, speed, reduce }: PartProps) { const stroke = Math.max(2, size * 0.09); const r = (size - stroke) / 2; return ( ); } function Dots({ size, speed, reduce }: PartProps) { const dot = size * 0.24; return ( {[0, 1, 2].map((i) => ( ))} ); } function Ascii({ frames, size, speed, reduce, }: PartProps & { frames: string[] }) { const [frame, setFrame] = useState(0); useEffect(() => { // Reduced motion slows the cycle rather than stopping it — it's a glyph // swap, not on-screen movement. const step = ((reduce ? speed * 2.5 : speed) / frames.length) * 1000; const id = setInterval( () => setFrame((f) => (f + 1) % frames.length), step, ); return () => clearInterval(id); }, [frames.length, speed, reduce]); return ( {frames[frame % frames.length]} ); } // Each shape is sampled at the same number of points and emitted as an SVG // path with identical command structure, so framer tweens the `d` attribute // point-to-point — a real morph, not a snap. (clip-path polygon strings don't // interpolate reliably in framer, which left the shapes broken.) const MORPH_POINTS = 24; function ngonRadius(ang: number, n: number, phase = 0) { const seg = (2 * Math.PI) / n; const a = ang - phase; const local = (((a % seg) + seg) % seg) - seg / 2; return Math.cos(Math.PI / n) / Math.cos(local); } function morphPath(radiusAt: (ang: number) => number) { const parts: string[] = []; for (let i = 0; i < MORPH_POINTS; i++) { const ang = (i / MORPH_POINTS) * 2 * Math.PI - Math.PI / 2; const r = Math.min(1.05, radiusAt(ang)); const x = (50 + Math.cos(ang) * 46 * r).toFixed(2); const y = (50 + Math.sin(ang) * 46 * r).toFixed(2); parts.push(`${i === 0 ? "M" : "L"}${x} ${y}`); } return `${parts.join(" ")} Z`; } const MORPH_PATHS = [ morphPath(() => 1), // circle morphPath((a) => ngonRadius(a, 4, Math.PI / 4)), // square morphPath((a) => ngonRadius(a, 3)), // triangle morphPath((a) => ngonRadius(a, 6)), // hexagon morphPath((a) => ngonRadius(a, 4)), // diamond ]; // Each shape appears twice in a row so it fully forms and HOLDS before the // next morph. Even keyframe spacing then alternates hold / morph segments. const MORPH_SEQ = [...MORPH_PATHS.flatMap((p) => [p, p]), MORPH_PATHS[0]]; // Rotation and scale only change across the morph segments, staying put on the // holds, so a settled shape sits still. const MORPH_ROT = [0, 0, 72, 72, 144, 144, 216, 216, 288, 288, 360]; const MORPH_SCALE = [1, 1, 0.88, 0.88, 1, 1, 0.88, 0.88, 1, 1, 1]; function Morph({ size, speed, reduce }: PartProps) { return ( Loading ); } function Comet({ size, speed, reduce }: PartProps) { const head = size * 0.2; const r = size / 2 - head / 2; const trail = [0, 1, 2, 3, 4, 5]; return ( {trail.map((i) => { const scale = 1 - i * 0.13; const sz = head * scale; return ( ); })} ); } const SCRAMBLE_TARGET = "LOADING"; const SCRAMBLE_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/*#@"; function Scramble({ size, speed, reduce }: PartProps) { const [text, setText] = useState(SCRAMBLE_TARGET); useEffect(() => { if (reduce) { setText(SCRAMBLE_TARGET); return; } let tick = 0; const total = SCRAMBLE_TARGET.length + 4; const id = setInterval( () => { const reveal = tick % total; let s = ""; for (let i = 0; i < SCRAMBLE_TARGET.length; i++) { s += i < reveal ? SCRAMBLE_TARGET[i] : SCRAMBLE_GLYPHS[ Math.floor(Math.random() * SCRAMBLE_GLYPHS.length) ]; } setText(s); tick++; }, (speed / SCRAMBLE_TARGET.length) * 1000 * 0.55, ); return () => clearInterval(id); }, [speed, reduce]); return ( {text} ); } function Metaballs({ size, speed, reduce }: PartProps) { const id = useId().replace(/:/g, ""); return ( Loading ); } function Newton({ size, speed, reduce }: PartProps) { const d = size * 0.2; const out = d * 1.1; const balls = [0, 1, 2, 3, 4]; // Only the end balls move: the left slides out and back on the first half, // then the right on the second half — the impact appears to jump the three // still middle balls. Pure horizontal slide, no swing, no strings. const moves: Record = { 0: { x: [0, -out, 0, 0], times: [0, 0.28, 0.5, 1] }, 4: { x: [0, 0, out, 0], times: [0, 0.5, 0.78, 1] }, }; return ( {balls.map((i) => { const move = moves[i]; return ( ); })} ); } function Helix({ size, speed, reduce }: PartProps) { const rows = 7; const dot = size * 0.14; const amp = size * 0.32; return ( {Array.from({ length: rows }, (_, r) => { const top = (r / (rows - 1)) * (size - dot); const delay = (r / rows) * speed; return ( ); })} ); } function Percent({ size, speed, reduce }: PartProps) { const [p, setP] = useState(0); useEffect(() => { const dur = (reduce ? speed * 2 : speed) * 1000; const start = { t: 0 }; const tickMs = 40; const id = setInterval(() => { start.t += tickMs; const next = Math.min(100, Math.round((start.t / dur) * 100)); setP(next); if (next >= 100) start.t = 0; }, tickMs); return () => clearInterval(id); }, [speed, reduce]); return ( {p}% ); } function Bars({ size, speed, reduce }: PartProps) { const bar = size * 0.16; return ( {[0, 1, 2, 3].map((i) => ( ))} ); } function DotMatrix({ size, speed, reduce }: PartProps) { const n = 3; const gap = size * 0.14; const dot = (size - gap * (n - 1)) / n; const cells = Array.from({ length: n * n }, (_, idx) => idx); return ( {cells.map((idx) => { const x = idx % n; const y = Math.floor(idx / n); // Diagonal wave: cells light in order of their distance from the corner. const delay = ((x + y) / (2 * (n - 1))) * speed; return ( ); })} ); } // Ordered Bayer 4x4 matrix — the classic dithering threshold pattern. Cells // light in this order, so the fill shimmers like a dissolving halftone. const BAYER_4 = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5, ]; function Dither({ size, speed, reduce }: PartProps) { const n = 4; const gap = Math.max(1, size * 0.05); const cell = (size - gap * (n - 1)) / n; return ( {BAYER_4.map((order, idx) => ( ))} ); }