For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Support

Loading

All 47 loading-ui animations in one component, selected with a typed variant prop.

Playground

Live preview
All 47 upstream variants are available. Reduced-motion preferences show a static indicator instead of looping animation.
Customize Loading
Customize
32.00
loading-demo.tsx
import { Loading } from "@/components/ui/loading";

export function Demo() {
  return <Loading variant="arc" size={32} />;
}

Installation

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/loading.json

The default installation target is components/ui/loading.tsx. Review the CLI's overwrite prompt before replacing local customizations.

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies2 required packages
  • cn
  • motion
$ pnpm add cn motion

Usage

import { Loading } from "@/components/ui/loading";
 
export function Demo() {
  return <Loading variant="arc" size={32} aria-label="Saving changes" />;
}

Omit variant to use arc. Installation adds the wrapper and variant list to components/ui/, plus all 47 loading-ui components and their MIT license to components/loading-ui/. Vandor UI distributes a source snapshot so the installed animations match the previews. Use your project's configured aliases if they differ.

The catalog matches loading-ui at the time of this release. Future upstream additions require a Vandor UI update; they do not silently become new variants.

Variants

Choose any name shown below with variant. This includes every visual component in loading-ui, not just circular spinners. The upstream index, style, and utils entries are infrastructure, not visual variants.

  • ring
  • spokes
  • classic
  • dots-ring
  • spiral
  • swirling
  • arc
  • dual-arc
  • fade-arc
  • concentric-ring
  • orbit-ring
  • satellite-ring
  • clock-ring
  • quarter-ring
  • dash-ring
  • comet-spinner
  • pulse
  • ripple
  • pulse-dot
  • dots
  • bobbing-dots
  • bouncing-dots
  • typing
  • twin-orbit
  • pulsating-dots
  • triple-dot-spinner
  • text-shimmer
  • text-blink
  • text-dots
  • text-shimmer-wave
  • morphing-infinity
  • infinity
  • accordion-loader
  • symmetric-wave
  • square-grid
  • square-accordion
  • conveyor-loop
  • square-snake
  • infinity-square-snake
  • infinity-track
  • diamond
  • wave
  • bars
  • analyzing-image
  • skeleton
  • terminal
  • wandering-eyes
loading-variants.tsx
import { Loading } from "@/components/ui/loading";
import { loadingVariants } from "@/components/ui/loading-variants";

export const LoadingVariants = () => (
  <ul className="m-0 grid list-none grid-cols-1 gap-0 p-0 sm:grid-cols-2 lg:grid-cols-3">
    {loadingVariants.map((variant) => (
      <li
        key={variant}
        className="flex min-w-0 flex-col items-center gap-5 border-b p-6"
      >
        <div
          className="flex h-24 w-full items-center justify-center"
          aria-hidden="true"
        >
          <Loading
            variant={variant}
            size={variant.startsWith("text-") ? 18 : 24}
          />
        </div>
        <code className="text-xs text-muted-foreground">{variant}</code>
      </li>
    ))}
  </ul>
);

Variant Configuration

variantProps preserves each upstream component's options. TypeScript checks them against the selected variant; for example, prompt belongs to terminal, not ring.

<Loading variant="dots-ring" variantProps={{ dots: 12, dotScale: 0.2 }} />
<Loading variant="bars" variantProps={{ bars: 5 }} />
<Loading variant="terminal" variantProps={{ prompt: "$" }} />
<Loading variant="square-grid" variantProps={{ size: 7, track: "·" }} />
<Loading
  variant="wandering-eyes"
  size={40}
  variantProps={{ eyeScale: 0.6, pupilScale: 0.25 }}
/>

size is the base visual size, in pixels or a CSS length. Text and glyph loaders use it as font size. Skeleton, eyes, and orbiting dots use proportional dimensions. variantProps.size, where supported, remains the upstream option (for example, the number of grid cells).

Use className for wrapper layout and inherited color. Use variantProps.className and variantProps.style for the visual itself:

<Loading variant="ring" className="text-primary" size="2rem" />
<Loading
  variant="skeleton"
  aria-label="Loading profile"
  variantProps={{ className: "rounded-none", style: { width: 160, height: 24 } }}
/>

Text

text supplies the visible content for all four text variants. It is separate from the accessible status label.

<Loading variant="text-dots" text="Uploading" aria-label="Uploading file" />
<Loading variant="text-blink" text="Thinking" variantProps={{ minOpacity: 0.3 }} />
<Loading variant="text-shimmer" text="Generating" variantProps={{ duration: 2, spread: 3 }} />
<Loading variant="text-shimmer-wave" text="Processing" variantProps={{ yDistance: -4 }} />

Animation Timing

duration sets the inherited --duration CSS variable in seconds for CSS-based loaders. Original fixed Motion and SVG SMIL timings remain unchanged, including spiral, morphing-infinity, analyzing-image, diamond, dash-ring, and ripple. For Motion loaders that expose a timing prop, use variantProps.duration:

<Loading variant="ring" duration={0.8} />
<Loading variant="bobbing-dots" variantProps={{ duration: 1.5 }} />
<Loading variant="pulsating-dots" variantProps={{ duration: 1.5 }} />

Optional Storybook Stories

Install Loading first, then run npx shadcn@latest add https://vandor-ui.vercel.app/r/loading-stories.json. Stories cover all 47 variants, text, CSS sizes, variant-specific options, and an accessible busy region. The Playground variant control is the lowest-cost way to inspect one animation at a time; AllVariants runs the whole catalog. Change your OS/browser reduced-motion preference to inspect the static fallback, which is not a component prop.

Stories install beside Loading under your configured UI alias and use relative imports. Keep Loading's installed animation snapshot and license. Use your existing matching @storybook/react packages (verified with 10.6.1), discover **/*.stories.tsx, and load your global Tailwind/theme stylesheet. No addons, extra packages, core overwrites, or Storybook config edits are required. Storybook UI is not verified because no server exists here.

Accessibility

The wrapper is a div with role="status" and the default accessible name Loading. Native div props and its ref are forwarded to the wrapper. All animated visuals are hidden from assistive technology to avoid duplicate announcements.

Provide a localized aria-label describing the operation. For a decorative indicator next to an existing status message, set aria-hidden on Loading. Render the loader only while work is pending, and use aria-busy on the region being updated when appropriate.

When prefers-reduced-motion is enabled, looping animations are replaced by a static ring, or static content for text variants. The accessible status name is preserved.

Props

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
variantLoadingVariant

Select any of the 47 loading-ui components. Names match the upstream registry.

"arc"
aria-labelstring

Accessible status name. Translate or customize for the current operation. Decorative use can set aria-hidden on the wrapper.

"Loading"
classNamestring

Layout and inherited color classes on the wrapper. Use variantProps.className to style the visual itself.

Not set
durationnumber

CSS animation cycle in seconds, via --duration. Fixed Motion and SVG SMIL timings are unchanged. For text shimmer, bobbing dots, and pulsating dots, use variantProps.duration.

Upstream default
sizenumber | string

Base visual size. Text and glyph variants use this as font size; skeleton and eyes use proportional dimensions. The playground starts at 32px.

24
textstring

Visible content for text-blink, text-dots, text-shimmer, and text-shimmer-wave.

"Loading"
variantPropsProps of selected loading-ui component

Typed upstream props for the selected variant, including dots, bars, prompt, glyphs, eye scales, colors, className, and style. Children are supplied through text; refs belong to the wrapper.

Not set

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

Source

"use client";
 
import { cn } from "cn";
import { useReducedMotion } from "motion/react";
import type { ComponentProps, ComponentType, CSSProperties } from "react";
 
import { AccordionLoader } from "@/components/loading-ui/accordion-loader";
import { AnalyzingImage } from "@/components/loading-ui/analyzing-image";
import { Arc } from "@/components/loading-ui/arc";
import { Bars } from "@/components/loading-ui/bars";
import { BobbingDots } from "@/components/loading-ui/bobbing-dots";
import { BouncingDots } from "@/components/loading-ui/bouncing-dots";
import { Classic } from "@/components/loading-ui/classic";
import { ClockRing } from "@/components/loading-ui/clock-ring";
import { CometSpinner } from "@/components/loading-ui/comet-spinner";
import { ConcentricRing } from "@/components/loading-ui/concentric-ring";
import { ConveyorLoop } from "@/components/loading-ui/conveyor-loop";
import { DashRing } from "@/components/loading-ui/dash-ring";
import { Diamond } from "@/components/loading-ui/diamond";
import { Dots } from "@/components/loading-ui/dots";
import { DotsRing } from "@/components/loading-ui/dots-ring";
import { DualArc } from "@/components/loading-ui/dual-arc";
import { FadeArc } from "@/components/loading-ui/fade-arc";
import { InfinityLoop } from "@/components/loading-ui/infinity";
import { InfinitySquareSnake } from "@/components/loading-ui/infinity-square-snake";
import { InfinityTrack } from "@/components/loading-ui/infinity-track";
import { MorphingInfinity } from "@/components/loading-ui/morphing-infinity";
import { OrbitRing } from "@/components/loading-ui/orbit-ring";
import { PulsatingDots } from "@/components/loading-ui/pulsating-dots";
import { Pulse } from "@/components/loading-ui/pulse";
import { PulseDot } from "@/components/loading-ui/pulse-dot";
import { QuarterRing } from "@/components/loading-ui/quarter-ring";
import { Ring } from "@/components/loading-ui/ring";
import { Ripple } from "@/components/loading-ui/ripple";
import { SatelliteRing } from "@/components/loading-ui/satellite-ring";
import { Skeleton } from "@/components/loading-ui/skeleton";
import { Spiral } from "@/components/loading-ui/spiral";
import { Spokes } from "@/components/loading-ui/spokes";
import { SquareAccordion } from "@/components/loading-ui/square-accordion";
import { SquareGrid } from "@/components/loading-ui/square-grid";
import { SquareSnake } from "@/components/loading-ui/square-snake";
import { Swirling } from "@/components/loading-ui/swirling";
import { SymmetricWave } from "@/components/loading-ui/symmetric-wave";
import { Terminal } from "@/components/loading-ui/terminal";
import { TextBlink } from "@/components/loading-ui/text-blink";
import { TextDots } from "@/components/loading-ui/text-dots";
import { TextShimmer } from "@/components/loading-ui/text-shimmer";
import { TextShimmerWave } from "@/components/loading-ui/text-shimmer-wave";
import { TripleDotSpinner } from "@/components/loading-ui/triple-dot-spinner";
import { TwinOrbit } from "@/components/loading-ui/twin-orbit";
import { Typing } from "@/components/loading-ui/typing";
import { WanderingEyes } from "@/components/loading-ui/wandering-eyes";
import { Wave } from "@/components/loading-ui/wave";
 
import { LoadingArc } from "./loading-arc";
import type { LoadingVariant } from "./loading-variants";
export { loadingVariants, type LoadingVariant } from "./loading-variants";
 
// Original animations: https://github.com/turbostarter/loading-ui (MIT).
// The registry includes a versioned snapshot as editable source.
const loadingComponents = {
  "accordion-loader": AccordionLoader,
  "analyzing-image": AnalyzingImage,
  arc: Arc,
  bars: Bars,
  "bobbing-dots": BobbingDots,
  "bouncing-dots": BouncingDots,
  classic: Classic,
  "clock-ring": ClockRing,
  "comet-spinner": CometSpinner,
  "concentric-ring": ConcentricRing,
  "conveyor-loop": ConveyorLoop,
  "dash-ring": DashRing,
  diamond: Diamond,
  dots: Dots,
  "dots-ring": DotsRing,
  "dual-arc": DualArc,
  "fade-arc": FadeArc,
  infinity: InfinityLoop,
  "infinity-square-snake": InfinitySquareSnake,
  "infinity-track": InfinityTrack,
  "morphing-infinity": MorphingInfinity,
  "orbit-ring": OrbitRing,
  "pulsating-dots": PulsatingDots,
  pulse: Pulse,
  "pulse-dot": PulseDot,
  "quarter-ring": QuarterRing,
  ring: Ring,
  ripple: Ripple,
  "satellite-ring": SatelliteRing,
  skeleton: Skeleton,
  spiral: Spiral,
  spokes: Spokes,
  "square-accordion": SquareAccordion,
  "square-grid": SquareGrid,
  "square-snake": SquareSnake,
  swirling: Swirling,
  "symmetric-wave": SymmetricWave,
  terminal: Terminal,
  "text-blink": TextBlink,
  "text-dots": TextDots,
  "text-shimmer": TextShimmer,
  "text-shimmer-wave": TextShimmerWave,
  "triple-dot-spinner": TripleDotSpinner,
  "twin-orbit": TwinOrbit,
  typing: Typing,
  "wandering-eyes": WanderingEyes,
  wave: Wave,
} as const satisfies Record<LoadingVariant, unknown>;
 
type LoadingBaseProps = Omit<ComponentProps<"div">, "children"> & {
  /** Base visual size in pixels, or a CSS length. Glyph/text variants use it as font size. */
  size?: number | string;
  /** CSS-based cycle duration in seconds. Fixed Motion/SMIL timings remain upstream defaults. */
  duration?: number;
  /** Visible content for the four text variants. */
  text?: string;
};
 
type SelectedLoadingProps = LoadingBaseProps &
  {
    [Variant in LoadingVariant]: {
      variant: Variant;
      variantProps?: Omit<
        ComponentProps<(typeof loadingComponents)[Variant]>,
        "children" | "ref"
      >;
    };
  }[LoadingVariant];
 
type DefaultLoadingProps = LoadingBaseProps & {
  variant?: "arc";
  variantProps?: Omit<ComponentProps<typeof Arc>, "children" | "ref">;
};
 
export type LoadingProps =
  | SelectedLoadingProps
  | DefaultLoadingProps
  | (LoadingBaseProps & {
      variant: LoadingVariant;
      variantProps?: never;
    });
 
const glyphVariants = new Set<LoadingVariant>([
  "accordion-loader",
  "conveyor-loop",
  "infinity-square-snake",
  "infinity-track",
  "square-accordion",
  "square-grid",
  "square-snake",
  "symmetric-wave",
  "terminal",
]);
 
const visualClassName = (variant: LoadingVariant) => {
  if (variant.startsWith("text-") || glyphVariants.has(variant)) {
    return "text-[length:var(--loading-size)]";
  }
  if (variant === "skeleton") {
    return "h-[calc(var(--loading-size)/2)] w-[calc(var(--loading-size)*4)]";
  }
  if (variant === "wandering-eyes") {
    return "h-(--loading-size) w-[calc(var(--loading-size)*2.25)]";
  }
  if (variant === "twin-orbit" || variant === "triple-dot-spinner") {
    return "size-[calc(var(--loading-size)/3)]";
  }
  return "size-(--loading-size)";
};
 
const StaticLoading = ({ isText, text }: { isText: boolean; text: string }) => {
  if (isText) {
    return <span className="text-[length:var(--loading-size)]">{text}</span>;
  }
  return (
    <svg viewBox="0 0 24 24" fill="none" className="size-(--loading-size)">
      <circle
        cx="12"
        cy="12"
        r="9"
        stroke="currentColor"
        strokeWidth="2"
        opacity="0.3"
      />
      <path
        d="M12 3a9 9 0 0 1 9 9"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
      />
    </svg>
  );
};
 
export const Loading = ({
  variant = "arc",
  variantProps,
  size = 24,
  duration,
  text = "Loading",
  className,
  style,
  "aria-label": ariaLabel = "Loading",
  ...props
}: LoadingProps) => {
  const reducedMotion = useReducedMotion();
  if (variant === "arc" && !variantProps) {
    return (
      <LoadingArc
        {...props}
        size={size}
        className={className}
        aria-label={ariaLabel}
        style={
          {
            ...(duration === undefined ? {} : { "--duration": `${duration}s` }),
            ...style,
          } as CSSProperties
        }
      />
    );
  }
  // The public discriminated union validates each variant's own props. The common
  // rendering boundary only adds className and string children to text components.
  const Component = loadingComponents[variant] as ComponentType<{
    className?: string;
    children?: string;
  }>;
  const isText = variant.startsWith("text-");
  return (
    <div
      role="status"
      aria-label={ariaLabel}
      data-slot="loading"
      data-variant={variant}
      className={cn(
        "inline-flex shrink-0 items-center justify-center align-middle",
        className
      )}
      style={
        {
          "--loading-size": typeof size === "number" ? `${size}px` : size,
          ...(duration === undefined ? {} : { "--duration": `${duration}s` }),
          ...style,
        } as CSSProperties
      }
      {...props}
    >
      <div
        aria-hidden="true"
        data-slot="loading-visual"
        className="inline-flex items-center justify-center"
      >
        {reducedMotion ? (
          <StaticLoading isText={isText} text={text} />
        ) : (
          <Component
            {...(isText && variant !== "text-dots" ? { as: "span" } : {})}
            {...variantProps}
            {...(isText ? { children: text } : {})}
            className={cn(visualClassName(variant), variantProps?.className)}
          />
        )}
      </div>
    </div>
  );
};
 

Credits

  • loading-ui: Original MIT-licensed animations by Bartosz Zagrodzki. Vandor UI adds a unified typed variant API, proportional sizing, accessible status labels, and a reduced-motion fallback.