React Component Kit
Base

Loader

Loader renders accessible ring, dots, bars, and pulse indicators. Pass `value` for a determinate ring with a percentage announced to screen readers.

Installation

npx shadcn@latest add https://rck.vibeboxph.com//r/loader.json

Live preview & controls

See related examples
Loading
Controls
<Loader />

Anatomy

  • LoaderThe root <span role="status"|"progressbar">.
  • visualThe selected ring, dots, bars, or pulse indicator.

Props

PropTypeDefaultDescription
size"sm" | "md" | "lg" | "xl" | numbermdPixel diameter.
tone"primary" | "muted" | "current"primaryStroke color.
variant"ring" | "dots" | "bars" | "pulse"ringVisual treatment for indeterminate loading.
valuenumber0-100. When set, renders a determinate ring instead of spinning.
thicknessnumber3Ring stroke width; applies to the ring variant only.
labelstringLoadingAccessible label for screen readers.

Accessibility

  • Uses `role="status"` when indeterminate and `role="progressbar"` (with `aria-valuenow/min/max`) when determinate.
  • The visible label is screen-reader-only by default (`sr-only`) — the spinner communicates progress visually.

Source

registry/rck/ui/loader.tsx
import * as React from "react";
import { cva, type VariantProps } from "@/lib/cva";
import { cn } from "@/lib/utils";

const loaderSizes = {
  sm: 16,
  md: 24,
  lg: 32,
  xl: 48,
} as const;

export type LoaderVariant = "ring" | "dots" | "bars" | "pulse";

export const loaderVariants = cva("inline-flex items-center gap-2", {
  variants: {
    tone: {
      primary: "text-primary",
      muted: "text-muted-foreground",
      current: "text-current",
    },
  },
  defaultVariants: {
    tone: "primary",
  },
});

export interface LoaderProps
  extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof loaderVariants> {
  size?: keyof typeof loaderSizes | number;
  /** Visual treatment for indeterminate loading. */
  variant?: LoaderVariant;
  /** 0-100. When provided, renders a determinate ring instead of an indeterminate spinner. */
  value?: number;
  /** Ring stroke width. Applies to the ring variant only. */
  thickness?: number;
  /** Accessible label announced to screen readers (default: "Loading"). */
  label?: string;
}

export const Loader = React.forwardRef<HTMLSpanElement, LoaderProps>(
  (
    {
      className,
      tone,
      size = "md",
      variant = "ring",
      value,
      thickness = 3,
      label = "Loading",
      ...props
    },
    ref,
  ) => {
    const numericSize = typeof size === "number" ? size : loaderSizes[size];
    const radius = Math.max(1, numericSize / 2 - thickness);
    const circumference = 2 * Math.PI * radius;
    const isDeterminate = typeof value === "number";
    const visualVariant: LoaderVariant = isDeterminate ? "ring" : variant;
    const clamped = isDeterminate ? Math.min(100, Math.max(0, value)) : 0;
    const offset = circumference - (clamped / 100) * circumference;

    return (
      <span
        ref={ref}
        data-slot="loader"
        role={isDeterminate ? "progressbar" : "status"}
        aria-valuenow={isDeterminate ? Math.round(clamped) : undefined}
        aria-valuemin={isDeterminate ? 0 : undefined}
        aria-valuemax={isDeterminate ? 100 : undefined}
        aria-label={label}
        data-variant={visualVariant}
        className={cn(loaderVariants({ tone }), className)}
        {...props}
      >
        {visualVariant === "ring" ? (
          <svg
            width={numericSize}
            height={numericSize}
            viewBox={`0 0 ${numericSize} ${numericSize}`}
            className={cn(!isDeterminate && "motion-safe:animate-spin")}
            aria-hidden="true"
          >
            <circle
              cx={numericSize / 2}
              cy={numericSize / 2}
              r={radius}
              fill="none"
              stroke="currentColor"
              strokeOpacity={isDeterminate ? 0.2 : 0.25}
              strokeWidth={thickness}
            />
            <circle
              cx={numericSize / 2}
              cy={numericSize / 2}
              r={radius}
              fill="none"
              stroke="currentColor"
              strokeWidth={thickness}
              strokeLinecap="round"
              strokeDasharray={circumference}
              strokeDashoffset={isDeterminate ? offset : circumference * 0.75}
              transform={`rotate(-90 ${numericSize / 2} ${numericSize / 2})`}
            />
          </svg>
        ) : null}
        {visualVariant === "dots" ? (
          <span
            className="flex items-center justify-center gap-1"
            style={{ width: numericSize, height: numericSize }}
            aria-hidden="true"
          >
            {[0, 1, 2].map((index) => (
              <span
                key={index}
                className="rounded-full bg-current motion-safe:animate-bounce"
                style={{
                  width: Math.max(3, numericSize * 0.18),
                  height: Math.max(3, numericSize * 0.18),
                  animationDelay: `${index * 120}ms`,
                }}
              />
            ))}
          </span>
        ) : null}
        {visualVariant === "bars" ? (
          <span
            className="flex items-center justify-center gap-1"
            style={{ width: numericSize, height: numericSize }}
            aria-hidden="true"
          >
            {[0, 1, 2].map((index) => (
              <span
                key={index}
                className="rounded-full bg-current motion-safe:animate-pulse"
                style={{
                  width: Math.max(2, numericSize * 0.14),
                  height: Math.max(6, numericSize * (index === 1 ? 0.72 : 0.45)),
                  animationDelay: `${index * 120}ms`,
                }}
              />
            ))}
          </span>
        ) : null}
        {visualVariant === "pulse" ? (
          <span
            className="rounded-full bg-current motion-safe:animate-ping"
            style={{
              width: numericSize * 0.5,
              height: numericSize * 0.5,
            }}
            aria-hidden="true"
          />
        ) : null}
        <span className="sr-only">{label}</span>
      </span>
    );
  },
);
Loader.displayName = "Loader";