Loader

Cloud Stack Loader

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.

Free, MIT licenseOpen in Browse
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 }

Installation

pnpm dlx shadcn add @01orbital/cloud-stack-loader
npx shadcn add @01orbital/cloud-stack-loader
yarn shadcn add @01orbital/cloud-stack-loader
bunx --bun shadcn add @01orbital/cloud-stack-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/cloud-stack-loader.tsx, as source you own and edit.

Usage

import { CloudStackLoader } from "@/components/loader/cloud-stack-loader"

export default function CloudStackLoaderDemo() {
  return (
    <>
      <CloudStackLoader size={48} />
    </>
  )
}

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.