Overview

Seven loading indicators drawn in the current text colour. spinner is an arc that grows and shrinks as it turns so it never looks stuck, ring is a twelve-spoke activity indicator, dots (alias pulse) breathe in turn, bounce squashes as it lands, bars is an equaliser, and orbit has two dots chasing around a track. Add text for a label with a soft shimmer. Each one is a status region with an accessible name, and reduced motion turns them all into a slow fade.


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 React from "react";import { motion, useReducedMotion } from "motion/react";import { cn } from "@/lib/utils";
type LoaderVariant = "spinner" | "ring" | "dots" | "pulse" | "bounce" | "bars" | "orbit";type LoaderSize = "xs" | "sm" | "md" | "lg" | "xl";
/** Overall box size in px. */const sizeMap: Record<LoaderSize, number> = { xs: 14, sm: 18, md: 24, lg: 32, xl: 44 };
export type LoaderProps = {  variant?: LoaderVariant;  size?: LoaderSize;  /**   * Any CSS colour (`"#f97316"`, `"var(--brand)"`). A Tailwind `bg-*` class is   * also accepted for older code. Defaults to the current text colour.   */  color?: string;  /** Visible text next to the loader, with a soft shimmer. */  text?: string;  /** Accessible name. Defaults to `text` or "Loading". */  label?: string;  className?: string;};
function Shimmer({ children, reduce }: { children: React.ReactNode; reduce: boolean }) {  return (    <motion.span      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]"      style={{ color: "var(--color-ink-2)" }}      animate={reduce ? undefined : { backgroundPosition: ["100% 0", "-50% 0"] }}      transition={{ duration: 1.6, repeat: Infinity, ease: "linear" }}    >      {children}    </motion.span>  );}
export function Loader({ variant = "spinner", size = "md", color, text, label, className }: LoaderProps) {  const reduce = !!useReducedMotion();  const s = sizeMap[size];  const legacyBg = color?.startsWith("bg-") ? color : undefined;  const colorStyle = color && !legacyBg ? { color } : undefined;  const dot = cn("rounded-full", legacyBg ?? "bg-current");  const loop = (duration: number, extra: object = {}) => ({ duration, repeat: Infinity, ease: "easeInOut" as const, ...extra });
  let body: React.ReactNode;
  // Reduced motion: every variant becomes a slow, gentle fade.  if (reduce) {    body = (      <motion.span        className={cn("block", dot)}        style={{ width: s * 0.5, height: s * 0.5 }}        animate={{ opacity: [1, 0.35, 1] }}        transition={loop(1.8)}      />    );  } else if (variant === "spinner") {    // An arc that grows and shrinks while it turns, so it never looks stuck.    body = (      <motion.svg viewBox="0 0 24 24" width={s} height={s} animate={{ rotate: 360 }} transition={{ duration: 1.1, repeat: Infinity, ease: "linear" }}>        <circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" strokeOpacity="0.14" strokeWidth="2.5" />        <motion.circle          cx="12"          cy="12"          r="9.5"          fill="none"          stroke="currentColor"          strokeWidth="2.5"          strokeLinecap="round"          pathLength={1}          strokeDasharray="1 1"          animate={{ strokeDasharray: ["0.08 1", "0.6 1", "0.08 1"], strokeDashoffset: [0, -0.35, -1] }}          transition={loop(1.5)}        />      </motion.svg>    );  } else if (variant === "ring") {    // Twelve spokes fading in turn, like a system activity indicator.    body = (      <svg viewBox="0 0 24 24" width={s} height={s}>        {Array.from({ length: 12 }, (_, i) => (          <motion.line            key={i}            x1="12"            y1="3"            x2="12"            y2="7"            stroke="currentColor"            strokeWidth="2"            strokeLinecap="round"            transform={`rotate(${i * 30} 12 12)`}            initial={{ opacity: 0.15 }}            animate={{ opacity: [1, 0.15] }}            transition={{ duration: 1, repeat: Infinity, ease: "linear", delay: i / 12 }}          />        ))}      </svg>    );  } else if (variant === "dots" || variant === "pulse") {    body = (      <span className="flex items-center" style={{ gap: s * 0.18 }}>        {[0, 1, 2].map((i) => (          <motion.span            key={i}            className={dot}            style={{ width: s * 0.26, height: s * 0.26 }}            animate={{ scale: [0.7, 1.15, 0.7], opacity: [0.35, 1, 0.35] }}            transition={loop(1, { delay: i * 0.16 })}          />        ))}      </span>    );  } else if (variant === "bounce") {    body = (      <span className="flex items-end" style={{ gap: s * 0.16, height: s }}>        {[0, 1, 2].map((i) => (          <motion.span            key={i}            className={dot}            style={{ width: s * 0.24, height: s * 0.24 }}            animate={{ y: [0, -s * 0.5, 0], scaleY: [0.85, 1.05, 0.85], scaleX: [1.12, 0.95, 1.12] }}            transition={loop(0.72, { delay: i * 0.12 })}          />        ))}      </span>    );  } else if (variant === "bars") {    body = (      <span className="flex items-center" style={{ gap: s * 0.12, height: s }}>        {[0, 1, 2, 3].map((i) => (          <motion.span            key={i}            className={cn("rounded-full", legacyBg ?? "bg-current")}            style={{ width: s * 0.14, height: s }}            animate={{ scaleY: [0.35, 1, 0.35] }}            transition={loop(0.9, { delay: i * 0.12 })}          />        ))}      </span>    );  } else {    // orbit: two dots chasing each other around a faint track.    body = (      <span className="relative block" style={{ width: s, height: s }}>        <span className="absolute inset-[12%] rounded-full border-2 border-current opacity-15" />        {[0, 1].map((i) => (          <motion.span            key={i}            className="absolute inset-0"            animate={{ rotate: 360 }}            transition={{ duration: 1.2, repeat: Infinity, ease: [0.6, 0.1, 0.4, 0.9], delay: i * 0.18 }}          >            <span              className={cn("absolute left-1/2 -translate-x-1/2", dot)}              style={{ top: 0, width: s * (i ? 0.18 : 0.26), height: s * (i ? 0.18 : 0.26), opacity: i ? 0.5 : 1 }}            />          </motion.span>        ))}      </span>    );  }
  return (    <span      role="status"      aria-label={label ?? text ?? "Loading"}      className={cn("inline-flex items-center gap-2.5 text-(--color-ink)", className)}      style={colorStyle}    >      <span aria-hidden className="inline-flex items-center justify-center" style={{ minWidth: s, minHeight: s }}>        {body}      </span>      {text ? (        <span aria-hidden>          <Shimmer reduce={reduce}>{text}</Shimmer>        </span>      ) : null}    </span>  );}

Update the import paths to match your project setup.

Basic Usage

tsx
import { Loader } from "@/components/ui/loader";
<Loader /><Loader variant="dots" size="sm" text="Generating preview" /><Loader variant="orbit" size="lg" color="#f97316" /><Loader variant="bars" className="text-emerald-500" />

Props

PropTypeRequiredDefaultDescription
variant"spinner" | "ring" | "dots" | "pulse" | "bounce" | "bars" | "orbit"No"spinner"Indicator style. pulse is the same as dots.
size"xs" | "sm" | "md" | "lg" | "xl"No"md"Box size from 14 to 44 px.
colorstringNo—Any CSS colour. Defaults to the text colour, so a text-* class works too. Older bg-* classes are still accepted.
textstringNo—Visible label with a shimmer, also used as the accessible name.
labelstringNo"Loading"Accessible name when there's no text.
classNamestringNo—Wrapper classes, e.g. a text colour.

Best Practices

  1. Show a loader only after a short delay (about 300 ms), so fast responses don't flash one.
  2. Prefer text for waits over a couple of seconds, so people know what's happening.
  3. Use one style across a product: spinner or ring for buttons and inline waits, dots or bars for larger empty areas.
  4. Every variant is a role="status" element, so screen readers hear the label once.