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.jsonLive preview & controls
See related examplesLoading
Controls
<Loader />Anatomy
LoaderThe root <span role="status"|"progressbar">.visualThe selected ring, dots, bars, or pulse indicator.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | "xl" | number | md | Pixel diameter. |
| tone | "primary" | "muted" | "current" | primary | Stroke color. |
| variant | "ring" | "dots" | "bars" | "pulse" | ring | Visual treatment for indeterminate loading. |
| value | number | — | 0-100. When set, renders a determinate ring instead of spinning. |
| thickness | number | 3 | Ring stroke width; applies to the ring variant only. |
| label | string | Loading | Accessible 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";