From f0d3b43161da785b53ece0cbd5bf2f1ba2115d6a Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sat, 4 Jul 2026 22:15:53 +0530 Subject: [PATCH] feat: add loader component with seventeen variants --- components/motion/loader.tsx | 609 ++++++++++++++++++ components/previews/index.tsx | 3 + components/previews/motion/loader.preview.tsx | 36 ++ lib/registry.ts | 15 + 4 files changed, 663 insertions(+) create mode 100644 components/motion/loader.tsx create mode 100644 components/previews/motion/loader.preview.tsx diff --git a/components/motion/loader.tsx b/components/motion/loader.tsx new file mode 100644 index 0000000..4af11d0 --- /dev/null +++ b/components/motion/loader.tsx @@ -0,0 +1,609 @@ +"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 str = size * 0.42; + const swing = 26; + const balls = [0, 1, 2, 3, 4]; + return ( + + {balls.map((i) => { + const isEnd = i === 0 || i === balls.length - 1; + const keyframes = + i === 0 + ? { rotate: [-swing, 0, 0, -swing] } + : { rotate: [0, 0, swing, 0] }; + 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) => ( + + ))} + + ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 840d37b..6d12c81 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -203,6 +203,9 @@ export const previews: Record = { (m) => m.CylinderCarouselPreview, ), ), + "motion/loader": dynamic(() => + import("./motion/loader.preview").then((m) => m.LoaderPreview), + ), }; export function getPreview(category: string, slug: string) { diff --git a/components/previews/motion/loader.preview.tsx b/components/previews/motion/loader.preview.tsx new file mode 100644 index 0000000..ff00137 --- /dev/null +++ b/components/previews/motion/loader.preview.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Loader, type LoaderVariant } from "@/components/motion/loader"; + +const VARIANTS: { variant: LoaderVariant; label: string }[] = [ + { variant: "spinner", label: "Spinner" }, + { variant: "dots", label: "Dots" }, + { variant: "bars", label: "Bars" }, + { variant: "dot-matrix", label: "Dot Matrix" }, + { variant: "dither", label: "Dither" }, + { variant: "morph", label: "Morph" }, + { variant: "comet", label: "Comet" }, + { variant: "metaballs", label: "Metaballs" }, + { variant: "newton", label: "Newton" }, + { variant: "helix", label: "Helix" }, + { variant: "scramble", label: "Scramble" }, + { variant: "percent", label: "Percent" }, + { variant: "ascii", label: "ASCII" }, + { variant: "ascii-line", label: "ASCII Line" }, + { variant: "ascii-braille", label: "ASCII Braille" }, + { variant: "ascii-blocks", label: "ASCII Blocks" }, + { variant: "ascii-bounce", label: "ASCII Bounce" }, +]; + +export function LoaderPreview() { + return ( +
+ {VARIANTS.map(({ variant, label }) => ( +
+ + {label} +
+ ))} +
+ ); +} diff --git a/lib/registry.ts b/lib/registry.ts index 03fe5eb..cd6e0f4 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -474,6 +474,21 @@ export const registry: CategoryEntry[] = [ "draggable carousel react", ], }, + { + slug: "loader", + name: "Loader", + description: + "Loading indicator with seventeen variants: spinner, dots, bars, dot-matrix, dither, morph, comet, scramble, metaballs, newton, helix, percent, and five terminal-style ascii spinners. Scales from one size prop, uses currentColor, and reduced-motion swaps every transform for a calm opacity pulse.", + file: "components/motion/loader.tsx", + badge: "new", + keywords: [ + "loader react", + "loading spinner", + "dot matrix loader", + "dithering loader", + "loading indicator", + ], + }, ], }, {