1"use client";2
3import React from "react";4import { motion, useReducedMotion } from "motion/react";5import { cn } from "@/lib/utils";6
7type LoaderVariant = "spinner" | "ring" | "dots" | "pulse" | "bounce" | "bars" | "orbit";8type LoaderSize = "xs" | "sm" | "md" | "lg" | "xl";9
1011const sizeMap: Record<LoaderSize, number> = { xs: 14, sm: 18, md: 24, lg: 32, xl: 44 };12
13export type LoaderProps = {14 variant?: LoaderVariant;15 size?: LoaderSize;16 17181920 color?: string;21 22 text?: string;23 24 label?: string;25 className?: string;26};27
28function Shimmer({ children, reduce }: { children: React.ReactNode; reduce: boolean }) {29 return (30 <motion.span31 className="bg-[linear-gradient(90deg,currentColor_0%,currentColor_40%,color-mix(in_oklab,currentColor_35%,transparent)_50%,currentColor_60%,currentColor_100%)] bg-[length:250%_100%] bg-clip-text text-[13px] font-medium [-webkit-background-clip:text] [-webkit-text-fill-color:transparent]"32 style={{ color: "var(--color-ink-2)" }}33 animate={reduce ? undefined : { backgroundPosition: ["100% 0", "-50% 0"] }}34 transition={{ duration: 1.6, repeat: Infinity, ease: "linear" }}35 >36 {children}37 </motion.span>38 );39}40
41export function Loader({ variant = "spinner", size = "md", color, text, label, className }: LoaderProps) {42 const reduce = !!useReducedMotion();43 const s = sizeMap[size];44 const legacyBg = color?.startsWith("bg-") ? color : undefined;45 const colorStyle = color && !legacyBg ? { color } : undefined;46 const dot = cn("rounded-full", legacyBg ?? "bg-current");47 const loop = (duration: number, extra: object = {}) => ({ duration, repeat: Infinity, ease: "easeInOut" as const, ...extra });48
49 let body: React.ReactNode;50
51 52 if (reduce) {53 body = (54 <motion.span55 className={cn("block", dot)}56 style={{ width: s * 0.5, height: s * 0.5 }}57 animate={{ opacity: [1, 0.35, 1] }}58 transition={loop(1.8)}59 />60 );61 } else if (variant === "spinner") {62 63 body = (64 <motion.svg viewBox="0 0 24 24" width={s} height={s} animate={{ rotate: 360 }} transition={{ duration: 1.1, repeat: Infinity, ease: "linear" }}>65 <circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" strokeOpacity="0.14" strokeWidth="2.5" />66 <motion.circle67 cx="12"68 cy="12"69 r="9.5"70 fill="none"71 stroke="currentColor"72 strokeWidth="2.5"73 strokeLinecap="round"74 pathLength={1}75 strokeDasharray="1 1"76 animate={{ strokeDasharray: ["0.08 1", "0.6 1", "0.08 1"], strokeDashoffset: [0, -0.35, -1] }}77 transition={loop(1.5)}78 />79 </motion.svg>80 );81 } else if (variant === "ring") {82 83 body = (84 <svg viewBox="0 0 24 24" width={s} height={s}>85 {Array.from({ length: 12 }, (_, i) => (86 <motion.line87 key={i}88 x1="12"89 y1="3"90 x2="12"91 y2="7"92 stroke="currentColor"93 strokeWidth="2"94 strokeLinecap="round"95 transform={`rotate(${i * 30} 12 12)`}96 initial={{ opacity: 0.15 }}97 animate={{ opacity: [1, 0.15] }}98 transition={{ duration: 1, repeat: Infinity, ease: "linear", delay: i / 12 }}99 />100 ))}101 </svg>102 );103 } else if (variant === "dots" || variant === "pulse") {104 body = (105 <span className="flex items-center" style={{ gap: s * 0.18 }}>106 {[0, 1, 2].map((i) => (107 <motion.span108 key={i}109 className={dot}110 style={{ width: s * 0.26, height: s * 0.26 }}111 animate={{ scale: [0.7, 1.15, 0.7], opacity: [0.35, 1, 0.35] }}112 transition={loop(1, { delay: i * 0.16 })}113 />114 ))}115 </span>116 );117 } else if (variant === "bounce") {118 body = (119 <span className="flex items-end" style={{ gap: s * 0.16, height: s }}>120 {[0, 1, 2].map((i) => (121 <motion.span122 key={i}123 className={dot}124 style={{ width: s * 0.24, height: s * 0.24 }}125 animate={{ y: [0, -s * 0.5, 0], scaleY: [0.85, 1.05, 0.85], scaleX: [1.12, 0.95, 1.12] }}126 transition={loop(0.72, { delay: i * 0.12 })}127 />128 ))}129 </span>130 );131 } else if (variant === "bars") {132 body = (133 <span className="flex items-center" style={{ gap: s * 0.12, height: s }}>134 {[0, 1, 2, 3].map((i) => (135 <motion.span136 key={i}137 className={cn("rounded-full", legacyBg ?? "bg-current")}138 style={{ width: s * 0.14, height: s }}139 animate={{ scaleY: [0.35, 1, 0.35] }}140 transition={loop(0.9, { delay: i * 0.12 })}141 />142 ))}143 </span>144 );145 } else {146 147 body = (148 <span className="relative block" style={{ width: s, height: s }}>149 <span className="absolute inset-[12%] rounded-full border-2 border-current opacity-15" />150 {[0, 1].map((i) => (151 <motion.span152 key={i}153 className="absolute inset-0"154 animate={{ rotate: 360 }}155 transition={{ duration: 1.2, repeat: Infinity, ease: [0.6, 0.1, 0.4, 0.9], delay: i * 0.18 }}156 >157 <span158 className={cn("absolute left-1/2 -translate-x-1/2", dot)}159 style={{ top: 0, width: s * (i ? 0.18 : 0.26), height: s * (i ? 0.18 : 0.26), opacity: i ? 0.5 : 1 }}160 />161 </motion.span>162 ))}163 </span>164 );165 }166
167 return (168 <span169 role="status"170 aria-label={label ?? text ?? "Loading"}171 className={cn("inline-flex items-center gap-2.5 text-(--color-ink)", className)}172 style={colorStyle}173 >174 <span aria-hidden className="inline-flex items-center justify-center" style={{ minWidth: s, minHeight: s }}>175 {body}176 </span>177 {text ? (178 <span aria-hidden>179 <Shimmer reduce={reduce}>{text}</Shimmer>180 </span>181 ) : null}182 </span>183 );184}