Loader

Ring Loader

Thin ring spinner in five variants, portal, arc, orbit, pulse and crescent, painted from a dusty rose and teal trio set as CSS variables. Reduced motion swaps every spin for a slow breath.

Free, MIT licenseOpen in Browse
import { cn } from "cn"

// Ring Loader — five calm ring loaders from the void set. All motion is
// transform/opacity only; reduced motion swaps spins for a slow breath.

// The dusty trio every variant draws from — override the vars to retint.
const PALETTE = {
  "--ld-a": "oklch(0.666 0.098 20.0)",
  "--ld-b": "oklch(0.771 0.061 351.3)",
  "--ld-c": "oklch(0.736 0.056 196.0)",
}

const RING_MASK =
  "radial-gradient(farthest-side, transparent calc(100% - 4px), black calc(100% - 3px))"

const CSS = `@keyframes ld-spin{to{transform:rotate(360deg)}}
@keyframes ld-pulse{0%,100%{opacity:0.6;transform:scale(1)}50%{opacity:1;transform:scale(0.92)}}
@keyframes ld-breathe{0%,100%{opacity:0.45}50%{opacity:1}}
@media (prefers-reduced-motion:reduce){[data-slot="ring-loader"] *{animation:ld-breathe 2.5s ease-in-out infinite !important}}`

type LoaderVariant = "portal" | "arc" | "orbit" | "pulse" | "crescent"

/**
 * A small ring spinner in one of five styles, for a loading state. Colors
 * come from the `--ld-a`, `--ld-b` and `--ld-c` variables, which you can
 * override through `style`; the faint tracks use `currentColor`.
 *
 * Exposes `role="status"` with the label "Loading"; pass `aria-label` to
 * change it. With reduced motion every spin and pulse becomes a slow opacity
 * breath.
 */
export function RingLoader({
  variant = "portal",
  size = 48,
  className,
  style,
  ...props
}: React.ComponentProps<"div"> & {
  /**
   * `portal` spins a three-color ring, `arc` a fading arc, `orbit` a dot
   * around a faint track, `pulse` pulses a ring and a centre dot, and
   * `crescent` spins an arc over a faint track.
   */
  variant?: LoaderVariant
  /** Width and height in px. */
  size?: number
}) {
  const ring = (background: string, animation: string) => (
    <i
      className="absolute inset-0 rounded-full"
      style={{
        background,
        mask: RING_MASK,
        animation,
      }}
    />
  )
  return (
    <div
      data-variant={variant}
      role="status"
      aria-label="Loading"
      className={cn("relative inline-block", className)}
      style={
        {
          width: size,
          height: size,
          ...PALETTE,
          ...style,
        } as React.CSSProperties
      }
      // After the spread: the reduced-motion CSS above selects on this slot.
      data-slot="ring-loader"
      {...props}
    >
      <style>{CSS}</style>

      {variant === "portal" &&
        ring(
          "conic-gradient(from 180deg, var(--ld-a), var(--ld-b), var(--ld-c), var(--ld-a))",
          "ld-spin 2s linear infinite"
        )}

      {variant === "arc" &&
        ring(
          "conic-gradient(from 0deg, var(--ld-b), transparent 120deg)",
          "ld-spin 1.8s linear infinite"
        )}

      {variant === "orbit" && (
        <>
          <i className="absolute inset-0 rounded-full border border-current/15" />
          <i className="absolute inset-0 animate-[ld-spin_1.5s_linear_infinite]">
            <i className="absolute top-0 left-1/2 size-2 -translate-x-1/2 rounded-full bg-(--ld-b) shadow-[0_0_10px_var(--ld-b),0_0_20px_var(--ld-a)]" />
          </i>
        </>
      )}

      {variant === "pulse" && (
        <>
          {ring(
            "conic-gradient(from 180deg, var(--ld-a), var(--ld-c), var(--ld-a))",
            "ld-pulse 2s ease-in-out infinite"
          )}
          <i className="absolute top-1/2 left-1/2 size-[30%] -translate-1/2 animate-[ld-pulse_2s_ease-in-out_0.2s_infinite] rounded-full bg-(--ld-b)" />
        </>
      )}

      {variant === "crescent" && (
        <>
          <i className="absolute inset-0 rounded-full border-2 border-current/8" />
          {ring(
            "conic-gradient(from 270deg, var(--ld-b), var(--ld-a), transparent 90deg, transparent)",
            "ld-spin 2s linear infinite"
          )}
        </>
      )}
    </div>
  )
}

Installation

pnpm dlx shadcn add @01orbital/ring-loader
npx shadcn add @01orbital/ring-loader
yarn shadcn add @01orbital/ring-loader
bunx --bun shadcn add @01orbital/ring-loader

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/loader/ring-loader.tsx, as source you own and edit.

Usage

import { RingLoader } from "@/components/loader/ring-loader"

const VARIANTS = ["portal", "arc", "orbit", "pulse", "crescent"] as const

// Five variants; the name is the point, so each one is captioned.
export default function RingLoaderDemo() {
  return (
    <div className="flex w-full flex-wrap place-items-center justify-center gap-14 p-6">
      {VARIANTS.map((variant) => (
        <figure key={variant} className="grid justify-items-center gap-4">
          <RingLoader variant={variant} size={40} />
          <figcaption className="text-sm text-muted-foreground">
            {variant}
          </figcaption>
        </figure>
      ))}
    </div>
  )
}

API reference

Prop

Type

It also accepts the HTML attributes of the element it renders.

Dependencies

npm: cn. The CLI installs them for you.

No other registry pieces.