# Loader

Four quiet loaders: dots, bars, orbit and ember pulse. Each announces itself to screen readers.

**Category:** Feedback  
**Exports:** `Loader`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/loader.json
```
The file lands at `components/ui/loader.tsx`; import from `@/components/ui/loader`.

## API — Loader
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `variant` | `"dots" \| "bars" \| "orbit" \| "pulse"` | `"dots"` |  |
| `size` | `number` | `24` |  |
| `label` | `string` | `"Loading"` |  |

## Example — Loader

```tsx
import { Loader } from "@/components/ui/loader";

export default function LoaderDemo({ size = 28 }: { size?: number }) {
  return (
    <div className="grid grid-cols-2 gap-10 sm:grid-cols-4">
      {(["dots", "bars", "orbit", "pulse"] as const).map((v) => (
        <div key={v} className="flex flex-col items-center gap-3">
          <Loader variant={v} size={size} />
          <span className="font-mono text-xs text-muted-foreground">{v}</span>
        </div>
      ))}
    </div>
  );
}
```

## Source — components/ui/loader.tsx

```tsx
"use client";

import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type LoaderProps = {
  variant?: "dots" | "bars" | "orbit" | "pulse";
  size?: number;
  label?: string;
  className?: string;
};

export function Loader({ variant = "dots", size = 24, label = "Loading", className }: LoaderProps) {
  const reduce = useReducedMotion();
  const loop = { repeat: Infinity, ease: "easeInOut" as const };

  const body = () => {
    if (variant === "dots") {
      const d = size / 4;
      return (
        <span className="flex items-center" style={{ gap: d / 1.5, height: size }}>
          {[0, 1, 2].map((i) => (
            <motion.span
              key={i}
              className="rounded-full bg-foreground"
              style={{ width: d, height: d }}
              animate={reduce ? { opacity: [0.3, 1, 0.3] } : { y: [0, -d, 0], opacity: [0.5, 1, 0.5] }}
              transition={{ ...loop, duration: 0.9, delay: i * 0.14 }}
            />
          ))}
        </span>
      );
    }
    if (variant === "bars") {
      return (
        <span className="flex items-center" style={{ gap: size / 10, height: size }}>
          {[0, 1, 2, 3].map((i) => (
            <motion.span
              key={i}
              className="rounded-full bg-foreground"
              style={{ width: size / 7, height: size }}
              animate={reduce ? { opacity: [0.3, 1, 0.3] } : { scaleY: [0.35, 1, 0.35] }}
              transition={{ ...loop, duration: 1, delay: i * 0.12 }}
            />
          ))}
        </span>
      );
    }
    if (variant === "orbit") {
      return (
        <span className="relative block" style={{ width: size, height: size }}>
          <span className="absolute inset-0 rounded-full border-2 border-muted" />
          <motion.span
            className="absolute inset-0"
            animate={reduce ? { opacity: [0.4, 1, 0.4] } : { rotate: 360 }}
            transition={{ repeat: Infinity, duration: reduce ? 1.4 : 0.9, ease: "linear" }}
          >
            <span
              className="absolute left-1/2 top-0 -translate-x-1/2 rounded-full bg-ember shadow-[0_0_8px_var(--color-ember)]"
              style={{ width: size / 5, height: size / 5, marginTop: -size / 20 }}
            />
          </motion.span>
        </span>
      );
    }
    return (
      <span className="relative grid place-items-center" style={{ width: size, height: size }}>
        {!reduce &&
          [0, 1].map((i) => (
            <motion.span
              key={i}
              className="absolute inset-0 rounded-full border border-ember"
              animate={{ scale: [0.4, 1.2], opacity: [0.8, 0] }}
              transition={{ repeat: Infinity, duration: 1.6, delay: i * 0.8, ease: "easeOut" }}
            />
          ))}
        <motion.span
          className="rounded-full bg-ember shadow-[0_0_14px_var(--color-ember)]"
          style={{ width: size / 3, height: size / 3 }}
          animate={{ opacity: [0.7, 1, 0.7] }}
          transition={{ ...loop, duration: 1.6 }}
        />
      </span>
    );
  };

  return (
    <span role="status" className={cn("inline-flex items-center", className)}>
      <span aria-hidden className="inline-flex">
        {body()}
      </span>
      <span className="sr-only">{label}</span>
    </span>
  );
}
```