Six CSS Loaders: Orbit, Bars, Morph, Ring, Pulse and Shimmer

Six loading indicators on one dark sheet: dots orbiting on three rings, an equaliser of bars, a square that morphs into a circle as it walks a path, a stretching ring, a pulse of ripples and a skeleton with shimmering text. Every one is plain CSS keyframes, so nothing needs a script to run. A speed control at the top changes all six at once. The grid is three columns on a desktop and two on a phone.

Code

import { useState } from "react";

const speeds = [
  { label: "Calm", value: 1.6 },
  { label: "Normal", value: 1 },
  { label: "Brisk", value: 0.6 },
];

const loaders = [
  { key: "orbit", name: "Orbit", note: "Three dots, three radii" },
  { key: "bars", name: "Bars", note: "Staggered equaliser" },
  { key: "morph", name: "Morph", note: "Square to circle" },
  { key: "ring", name: "Ring", note: "Stretching arc" },
  { key: "pulse", name: "Pulse", note: "Expanding ripples" },
  { key: "shimmer", name: "Shimmer", note: "Skeleton and text" },
];

const css = `
.six-loaders-root { --sl: 1; }
@keyframes six-loaders-spin { to { transform: rotate(360deg); } }
@keyframes six-loaders-bar { 0%, 100% { transform: scaleY(0.25); opacity: 0.45; } 50% { transform: scaleY(1); opacity: 1; } }
@keyframes six-loaders-morph {
  0% { transform: translate(-22px, -22px) rotate(0deg) scale(0.9); border-radius: 10px; }
  25% { transform: translate(22px, -22px) rotate(90deg) scale(1.1); border-radius: 50%; }
  50% { transform: translate(22px, 22px) rotate(180deg) scale(0.9); border-radius: 10px; }
  75% { transform: translate(-22px, 22px) rotate(270deg) scale(1.1); border-radius: 50%; }
  100% { transform: translate(-22px, -22px) rotate(360deg) scale(0.9); border-radius: 10px; }
}
@keyframes six-loaders-dash {
  0% { stroke-dasharray: 2 150; stroke-dashoffset: 0; }
  50% { stroke-dasharray: 96 150; stroke-dashoffset: -28; }
  100% { stroke-dasharray: 96 150; stroke-dashoffset: -150; }
}
@keyframes six-loaders-ripple { 0% { transform: scale(0.2); opacity: 0.9; } 100% { transform: scale(1); opacity: 0; } }
@keyframes six-loaders-beat { 0%, 100% { transform: scale(0.85); } 50% { transform: scale(1.15); } }
@keyframes six-loaders-sweep { 0% { background-position: 180% 0; } 100% { background-position: -80% 0; } }
.six-loaders-spin { animation: six-loaders-spin calc(var(--sl) * var(--d, 1.4s)) linear infinite; }
.six-loaders-bar { transform-origin: 50% 100%; animation: six-loaders-bar calc(var(--sl) * 1.1s) ease-in-out infinite; }
.six-loaders-morph { animation: six-loaders-morph calc(var(--sl) * 2.4s) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
.six-loaders-dash { animation: six-loaders-dash calc(var(--sl) * 1.5s) ease-in-out infinite; }
.six-loaders-ripple { animation: six-loaders-ripple calc(var(--sl) * 2.1s) cubic-bezier(0.2, 0.6, 0.35, 1) infinite; }
.six-loaders-beat { animation: six-loaders-beat calc(var(--sl) * 1.05s) ease-in-out infinite; }
.six-loaders-sweep {
  background-image: linear-gradient(100deg, rgba(255,255,255,0.1) 30%, rgba(217,249,157,0.6) 50%, rgba(255,255,255,0.1) 70%);
  background-size: 260% 100%;
  animation: six-loaders-sweep calc(var(--sl) * 1.8s) linear infinite;
}
.six-loaders-text {
  background-image: linear-gradient(100deg, #a1a1aa 35%, #ecfccb 50%, #a1a1aa 65%);
  background-size: 260% 100%;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  animation: six-loaders-sweep calc(var(--sl) * 1.8s) linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .six-loaders-root * { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
  .six-loaders-text { color: #d4d4d8; background-image: none; }
}
`;

function Orbit() {
  const rings = [
    { size: 84, d: "1.9s", dot: 8, reverse: false, tone: "bg-lime-300" },
    { size: 56, d: "1.3s", dot: 7, reverse: true, tone: "bg-lime-200" },
    { size: 28, d: "0.9s", dot: 6, reverse: false, tone: "bg-white" },
  ];
  return (
    <div className="relative h-24 w-24">
      {rings.map((r) => (
        <div
          key={r.size}
          className="six-loaders-spin absolute left-1/2 top-1/2 rounded-full border border-white/10"
          style={{ width: r.size, height: r.size, marginLeft: -r.size / 2, marginTop: -r.size / 2, ["--d" as string]: r.d, animationDirection: r.reverse ? "reverse" : "normal" }}
        >
          <span className={`absolute left-1/2 rounded-full ${r.tone}`} style={{ width: r.dot, height: r.dot, top: -r.dot / 2, marginLeft: -r.dot / 2, boxShadow: "0 0 14px rgba(190,242,100,0.9)" }} />
        </div>
      ))}
      <span className="absolute left-1/2 top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-lime-300/70" />
    </div>
  );
}

function Bars() {
  return (
    <div className="flex h-20 items-end gap-2">
      {[0, 1, 2, 3, 4, 5, 6].map((i) => (
        <span
          key={i}
          className="six-loaders-bar block h-full w-2 rounded-full bg-gradient-to-t from-lime-500 to-lime-200"
          style={{ animationDelay: `calc(var(--sl) * ${-1.1 + Math.abs(3 - i) * 0.13}s)` }}
        />
      ))}
    </div>
  );
}

function Morph() {
  return (
    <div className="relative flex h-24 w-24 items-center justify-center">
      <span className="absolute h-[68px] w-[68px] rounded-2xl border border-dashed border-white/15" />
      <span className="six-loaders-morph block h-7 w-7 bg-lime-300" style={{ boxShadow: "0 0 24px rgba(190,242,100,0.55)" }} />
    </div>
  );
}

function Ring() {
  return (
    <div className="relative h-24 w-24">
      <svg viewBox="0 0 60 60" className="six-loaders-spin h-full w-full" style={{ ["--d" as string]: "2s" }} aria-hidden="true">
        <circle cx="30" cy="30" r="24" fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="5" />
        <circle cx="30" cy="30" r="24" fill="none" stroke="#bef264" strokeWidth="5" strokeLinecap="round" className="six-loaders-dash" style={{ filter: "drop-shadow(0 0 5px rgba(190,242,100,0.7))" }} />
      </svg>
    </div>
  );
}

function Pulse() {
  return (
    <div className="relative flex h-24 w-24 items-center justify-center">
      {[0, 1, 2].map((i) => (
        <span key={i} className="six-loaders-ripple absolute inset-0 rounded-full border-2 border-lime-300" style={{ animationDelay: `calc(var(--sl) * ${-i * 0.7}s)` }} />
      ))}
      <span className="six-loaders-beat block h-5 w-5 rounded-full bg-lime-300" style={{ boxShadow: "0 0 22px rgba(190,242,100,0.8)" }} />
    </div>
  );
}

function Shimmer() {
  return (
    <div className="w-full max-w-[220px]">
      <div className="flex items-center gap-3">
        <span className="six-loaders-sweep h-10 w-10 flex-none rounded-full" />
        <div className="flex-1 space-y-2">
          <span className="six-loaders-sweep block h-2.5 w-4/5 rounded-full" />
          <span className="six-loaders-sweep block h-2.5 w-1/2 rounded-full" />
        </div>
      </div>
      <span className="six-loaders-sweep mt-3 block h-2.5 w-full rounded-full" />
      <p className="six-loaders-text mt-3 text-sm font-medium">Loading your workspace</p>
    </div>
  );
}

const parts: Record<string, () => JSX.Element> = { orbit: Orbit, bars: Bars, morph: Morph, ring: Ring, pulse: Pulse, shimmer: Shimmer };

export default function SixLoadersOrbitBarsMorphRingPulseShimmer() {
  const [speed, setSpeed] = useState(1);
  return (
    <section className="six-loaders-root relative overflow-hidden bg-zinc-950 px-5 py-14 text-white sm:px-8 sm:py-20" style={{ ["--sl" as string]: speed }}>
      <style>{css}</style>
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-60"
        style={{ backgroundImage: "radial-gradient(60% 50% at 50% 0%, rgba(163,230,53,0.14), transparent 70%), linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px)", backgroundSize: "100% 100%, 44px 44px, 44px 44px" }}
      />
      <div className="relative mx-auto max-w-5xl">
        <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-lg">
            <p className="font-mono text-xs uppercase tracking-[0.2em] text-lime-300">Loading states</p>
            <h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">Six ways to say one moment</h2>
            <p className="mt-3 text-zinc-400">Pure CSS, no images and no script. Pick the one that fits the wait and paste it in.</p>
          </div>
          <div role="group" aria-label="Animation speed" className="inline-flex self-start rounded-full border border-white/10 bg-white/5 p-1 sm:self-auto">
            {speeds.map((s) => (
              <button
                key={s.label}
                type="button"
                aria-pressed={speed === s.value}
                onClick={() => setSpeed(s.value)}
                className={`min-h-[40px] rounded-full px-4 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 ${speed === s.value ? "bg-lime-300 text-zinc-950" : "text-zinc-300 hover:text-white"}`}
              >
                {s.label}
              </button>
            ))}
          </div>
        </div>
        <ul className="mt-10 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3">
          {loaders.map((l, i) => {
            const Part = parts[l.key];
            return (
              <li key={l.key} className="group relative flex flex-col overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/70">
                <span aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-lime-300/50 to-transparent" />
                <div role="status" aria-label={`${l.name} loader`} className="flex h-40 items-center justify-center px-4 sm:h-48">
                  <Part />
                </div>
                <div className="flex items-center justify-between gap-2 border-t border-white/10 px-4 py-3">
                  <div className="min-w-0">
                    <p className="text-sm font-semibold">{l.name}</p>
                    <p className="truncate text-xs text-zinc-400">{l.note}</p>
                  </div>
                  <span className="font-mono text-xs text-zinc-500">0{i + 1}</span>
                </div>
              </li>
            );
          })}
        </ul>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/six-loaders-orbit-bars-morph-ring-pulse-shimmer.json

When to use it

How to customize it

Accessibility

Questions

How do the loaders animate without JavaScript?

Each one is a few elements with a CSS `@keyframes` animation. The ring animates `stroke-dasharray` on an SVG circle while the whole SVG rotates, the bars scale on the Y axis with staggered negative delays, and the shimmer slides a wide gradient across the element with `background-position`.

How does the shimmer text effect work?

The text has a three-stop gradient as its background, `background-clip: text` and a transparent colour, so the gradient shows through the letters. Animating `background-position` moves the bright band from one side to the other.

Why do the delays use negative values?

A negative `animation-delay` starts the animation part way through its cycle. That means the bars and ripples are already spread out on the first frame instead of starting together and drifting apart.

Which loader should I use where?

Use the ring or orbit for short waits inside buttons and cards, bars for audio or processing, pulse for something live such as a location or a recording, and the shimmer skeleton when you know the shape of the content that is about to appear.

Does this need framer-motion?

No. It imports only `useState` from React for the speed control. Remove the control and the component is static markup plus CSS.

Related: Multi-Step Loading Sequence with Drawn Checkmarks, Dismissible Alert Set: Success, Warning, Error and Info, Button Set: Primary, Secondary, Ghost, Icon and Loading.

More loaders or all free UI components. Start for free to have a full site built around it.

More in Loaders

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap