Vertical column of three paper circles that take their color in turn from top to bottom while the others rest in grey. Height sets the size; reduced motion fades instead of scaling.
import { cn } from "cn"
const KEYFRAMES = `
@keyframes cloud-stack-loader-lit {
0%,
45%,
100% {
transform: scale(0.9);
opacity: 0.7;
filter: saturate(0);
}
12%,
30% {
transform: scale(1);
opacity: 1;
filter: saturate(1);
}
}
@keyframes cloud-stack-loader-fade {
to {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="cloud-loader-stack"] [data-motion] {
animation-name: cloud-stack-loader-fade !important;
animation-duration: 1.6s !important;
animation-timing-function: ease-in-out !important;
animation-direction: alternate !important;
}
}
`
/**
* A column of paper circles for a loading state. Each takes its tint in turn,
* top to bottom, while the others rest grey and slightly smaller.
*
* Exposes `role="status"` with the label "Loading"; pass `aria-label` to
* change it. With reduced motion the circles pulse in opacity instead.
*/
export function CloudStackLoader({
palettes = [
{
main: "oklch(0.638 0.107 20.5)",
accent: "oklch(0.616 0.057 195.8)",
},
{
main: "oklch(0.616 0.057 195.8)",
accent: "oklch(0.582 0.122 31.3)",
},
{
main: "oklch(0.69 0.097 88.5)",
accent: "oklch(0.616 0.057 195.8)",
},
],
size = 64,
className,
style,
...props
}: Omit<CloudLoaderProps, "palette"> & {
/** One tint per circle, top to bottom; the list length sets the count. */
palettes?: CloudPalette[]
}) {
const height = palettes.length * 32 + (palettes.length - 1) * 4
const scale = size / height
const cycle = palettes.length * 0.8
return (
<div
role="status"
aria-label="Loading"
className={cn("relative", className)}
style={{ width: 32 * scale, height: size, ...style }}
{...props}
// After the spread: the reduced-motion CSS above selects on this slot.
data-slot="cloud-loader-stack"
>
<style href="cloud-stack-loader" precedence="default">
{KEYFRAMES}
</style>
{/* Authored with 32px circles; size scales the whole column from there. */}
<div
className="absolute top-0 left-0 flex flex-col gap-1"
style={{ zoom: scale }}
>
{palettes.map((p, i) => (
<div
key={i}
data-motion
className="size-8"
style={{
...cloudLoaderBody(p),
animation: `cloud-stack-loader-lit ${cycle}s ${CLOUD_LOADER_EASE} infinite`,
animationDelay: `${(i * cycle) / palettes.length}s`,
}}
/>
))}
</div>
</div>
)
}
const CLOUD_LOADER_EASE = "cubic-bezier(0.45, 0, 0.55, 1)"
const PAPER =
"radial-gradient(circle at 35% 35%, oklch(1 0 0) 0%, oklch(0.995 0.002 90) 40%, oklch(0.915 0.006 85) 100%)"
const cloudLoaderBody = (palette: CloudPalette): React.CSSProperties => ({
borderRadius: "50%",
background: PAPER,
boxShadow: [
`inset -6px -8px 16px color-mix(in oklch, ${palette.main} 25%, transparent)`,
`inset -3px -4px 8px color-mix(in oklch, ${palette.main} 15%, transparent)`,
"inset 4px 5px 10px oklch(1 0 0 / 0.8)",
"inset 2px 3px 5px oklch(1 0 0 / 0.6)",
`inset -8px -10px 20px color-mix(in oklch, ${palette.accent} 10%, transparent)`,
`0 4px 12px color-mix(in oklch, ${palette.main} 15%, transparent)`,
"0 2px 4px oklch(0 0 0 / 0.05)",
].join(", "),
})
type CloudLoaderProps = React.ComponentProps<"div"> & {
palette?: CloudPalette
/** Box height in px. Everything inside scales with it. */
size?: number
}
export type CloudPalette = { main: string; accent: string }
pnpm dlx shadcn add @01orbital/cloud-stack-loadernpx shadcn add @01orbital/cloud-stack-loaderyarn shadcn add @01orbital/cloud-stack-loaderbunx --bun shadcn add @01orbital/cloud-stack-loaderFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/loader/cloud-stack-loader.tsx, as source you own and edit.
import { CloudStackLoader } from "@/components/loader/cloud-stack-loader"
export default function CloudStackLoaderDemo() {
return (
<>
<CloudStackLoader size={48} />
</>
)
}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.
Breathe Loader
Single paper-lit sphere with a crescent tint that slowly swells and settles on a three-second loop. Sized in pixels; reduced motion replaces the scale change with an opacity fade.
Crescent Spin Loader
Paper-lit sphere with a round bite masked out, spinning as a crescent that works on any background. A Loading status sized in pixels; reduced motion turns the spin into a fade.