Base

Loader

Loader renders an accessible spinner. Pass `value` for a determinate ring with a percentage announced to screen readers.

Installation

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

Live preview & controls

Loading
Controls
<Loader />

Anatomy

  • LoaderThe root <span role="status"|"progressbar">.
  • svgThe animated or determinate ring.

Props

PropTypeDefaultDescription
size"sm" | "md" | "lg" | "xl" | numbermdPixel diameter.
tone"primary" | "muted" | "current"primaryStroke color.
valuenumber0-100. When set, renders a determinate ring instead of spinning.
thicknessnumber3Stroke width.
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 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;
  /** 0-100. When provided, renders a determinate ring instead of an indeterminate spinner. */
  value?: number;
  thickness?: number;
  /** Accessible label announced to screen readers (default: "Loading"). */
  label?: string;
}

export const Loader = React.forwardRef<HTMLSpanElement, LoaderProps>(
  (
    { className, tone, size = "md", value, thickness = 3, label = "Loading", ...props },
    ref,
  ) => {
    const numericSize = typeof size === "number" ? size : loaderSizes[size];
    const radius = numericSize / 2 - thickness;
    const circumference = 2 * Math.PI * radius;
    const isDeterminate = typeof value === "number";
    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}
        className={cn(loaderVariants({ tone }), className)}
        {...props}
      >
        <svg
          width={numericSize}
          height={numericSize}
          viewBox={`0 0 ${numericSize} ${numericSize}`}
          className={cn(!isDeterminate && "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>
        <span className="sr-only">{label}</span>
      </span>
    );
  },
);
Loader.displayName = "Loader";

Related examples

Browse the full examples gallery