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.
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>
)
}
pnpm dlx shadcn add @01orbital/ring-loadernpx shadcn add @01orbital/ring-loaderyarn shadcn add @01orbital/ring-loaderbunx --bun shadcn add @01orbital/ring-loaderFree 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.
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>
)
}Prop
Type
It also accepts the HTML attributes of the element it renders.
npm: cn. The CLI installs them for you.
No other registry pieces.
Orbit Loader
Paper-lit centre sphere circled by three small moons on one orbit, the first tinted to match the centre. The moons keep their spacing when still; reduced motion fades them in sequence.
Rothko Loader
Small Rothko-style canvas of two color fields on a ground, breathing under grain and crossfading through any palettes you pass. Three sizes and a grisaille option; client component, reduced motion keeps only the fade.