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.jsonLive preview & controls
Loading
Controls
<Loader />Anatomy
LoaderThe root <span role="status"|"progressbar">.svgThe animated or determinate ring.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | "xl" | number | md | Pixel diameter. |
| tone | "primary" | "muted" | "current" | primary | Stroke color. |
| value | number | — | 0-100. When set, renders a determinate ring instead of spinning. |
| thickness | number | 3 | Stroke width. |
| 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 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
Sizes & tonesSize scale and tone options.
Inline with textIndeterminate and determinate loaders next to status text.