Installation

Use the following command to install the component:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/loader.json

Install the following dependencies:

$ pnpm add motion clsx tailwind-merge

Add util file

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy and paste the following code into your project.

components/ui/loader.tsx
tsx
"use client";
import { motion, useReducedMotion } from "motion/react";import clsx from "clsx";
type LoaderVariant = "spinner" | "pulse" | "bounce";type LoaderSize = "xs" | "sm" | "md" | "lg" | "xl";
const sizeMap: Record<LoaderSize, number> = {  xs: 8,  sm: 12,  md: 16,  lg: 24,  xl: 32,};
interface LoaderProps {  variant?: LoaderVariant;  size?: LoaderSize;  color?: string;  className?: string;}
export function Loader({  variant = "spinner",  size = "md",  color = "bg-primary",  className,}: LoaderProps) {  const reduce = useReducedMotion();  const numericSize = sizeMap[size];
  if (reduce) {    // Gentler: soft opacity pulse, no travel / spin    return (      <div        className={clsx("flex items-center justify-center gap-2", className)}        role="status"        aria-label="Loading"      >        {[0, 1, 2].map((i) => (          <motion.div            key={i}            className={clsx("rounded-full", color)}            style={{ width: numericSize, height: numericSize }}            animate={{ opacity: [0.35, 1, 0.35] }}            transition={{              duration: 1.2,              repeat: Infinity,              delay: i * 0.15,              ease: "easeInOut",            }}          />        ))}      </div>    );  }
  switch (variant) {    case "pulse":      return (        <div className={clsx("flex gap-2", className)} role="status" aria-label="Loading">          {[0, 1, 2].map((i) => (            <motion.div              key={i}              className={clsx("rounded-full", color)}              style={{ width: numericSize, height: numericSize }}              animate={{ scale: [1, 1.25, 1], opacity: [1, 0.55, 1] }}              transition={{                duration: 0.9,                repeat: Infinity,                delay: i * 0.15,                ease: [0.23, 1, 0.32, 1],              }}            />          ))}        </div>      );
    case "bounce":      return (        <div className={clsx("flex gap-2", className)} role="status" aria-label="Loading">          {[0, 1, 2].map((i) => (            <motion.div              key={i}              className={clsx("rounded-full", color)}              style={{ width: numericSize, height: numericSize }}              animate={{ y: [0, -10, 0] }}              transition={{                duration: 0.7,                repeat: Infinity,                delay: i * 0.12,                ease: [0.23, 1, 0.32, 1],              }}            />          ))}        </div>      );
    case "spinner":    default:      return (        <motion.div          role="status"          aria-label="Loading"          className={clsx(            "rounded-full border-4 border-t-transparent bg-transparent",            color,            className,          )}          style={{            width: numericSize * 3,            height: numericSize * 3,          }}          animate={{ rotate: 360 }}          transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }}        />      );  }}

Update the import paths to match your project setup.

Basic Usage

tsx
import { Loader } from "@/components/ui/loader";
function MyComponent() {  return (    <div>      <Loader variant="spinner" size="md" />    </div>  );}

Variants

A classic spinning loader with customizable colors.

tsx
<Loader variant="spinner" />

Bouncing dots that animate in a wave pattern.

tsx
<Loader variant="bounce" />

A pulsing circle with fade effects.

tsx
<Loader variant="pulse" />

A morphing loader that changes shape and color.

tsx
<Loader variant="morph" />

Sizes

Loaders come in four sizes:

tsx
<Loader size="sm" />   // Small<Loader size="md" />   // Medium (default)<Loader size="lg" />   // Large<Loader size="xl" />   // Extra large

Props

PropTypeRequiredDefaultDescription
variantstringNoThe type of loading animation
sizestringNoThe size of the loader
colorstringNoThe color of the loader
speedstringNoAnimation speed
classNamestringNoAdditional CSS classes

Examples

tsx
function LoadingButton() {  const [isLoading, setIsLoading] = useState(false);
  return (    <Button onClick={() => setIsLoading(true)} disabled={isLoading}>      {isLoading ? (        <>          <Loader size="sm" className="mr-2" />          Loading...        </>      ) : (        "Submit"      )}    </Button>  );}

Best Practices

  1. Appropriate Size: Use smaller loaders for buttons, larger for page loads
  2. Consistent Placement: Keep loaders in consistent locations across your app
  3. Meaningful Text: Include descriptive text with loaders when appropriate
  4. Accessibility: Always provide ARIA labels for screen readers
  5. Performance: Use lightweight animations for better performance

Accessibility

  • ARIA Labels: Proper labels for screen readers
  • Reduced Motion: Respect user's motion preferences
  • Focus Management: Proper focus handling during loading states
  • Color Contrast: Sufficient contrast for visibility

Performance Tips

  1. CSS Animations: Use CSS animations when possible for better performance
  2. Reduced Motion: Respect prefers-reduced-motion media query
  3. Optimized Bundles: Tree-shake unused loader variants
  4. Lazy Loading: Load heavy animations only when needed

Troubleshooting

Check if Framer Motion is properly installed and the component is wrapped in a motion provider.

Consider using CSS-only animations for better performance on low-end devices.

Ensure proper ARIA labels and test with screen readers.

Check for conflicting CSS classes or Tailwind CSS purging issues.