Shadcn Circular Progress for React and Tailwind
Shows task progress with a circular indicator.
Installation
bunx --bun shadcn@latest add https://kit.dev/r/circular-progress.jsonpnpm dlx shadcn@latest add https://kit.dev/r/circular-progress.jsonnpx shadcn@latest add https://kit.dev/r/circular-progress.jsonyarn shadcn@latest add https://kit.dev/r/circular-progress.jsonInstall the following dependencies:
bun add @ark-ui/reactpnpm add @ark-ui/reactnpm install @ark-ui/reactyarn add @ark-ui/reactCopy and paste the following code into your project.
"use client";
import {
Progress as ArkProgress,
useProgress as useArkProgress,
useProgressContext as useArkProgressContext,
} from "@ark-ui/react/progress";
import type React from "react";
import { cn } from "@/lib/utils";
export const useCircularProgress = useArkProgress;
export const useCircularProgressContext = useArkProgressContext;
export const CircularProgressContext = ArkProgress.Context;
const DEFAULT_SIZE = 32;
const DEFAULT_THICKNESS = 4;
interface CircularProgressSizeProps {
/**
* Width and height of the circle, in pixels.
*
* @default 32
*/
size?: number;
/**
* Stroke width of the circle, in pixels.
*
* @default 4
*/
thickness?: number;
}
const circularProgressRootClassName = cn(
"group/circular-progress",
"relative",
"inline-flex flex-col items-center justify-center"
);
const sizeStyle = (size: number, thickness: number) =>
({
"--size": `${size}px`,
"--thickness": `${thickness}px`,
}) as React.CSSProperties;
export const CircularProgress = (
props: React.ComponentProps<typeof ArkProgress.Root> &
CircularProgressSizeProps & {
/**
* Shows indeterminate progress (`value={null}`).
*
* @default false
*/
indeterminate?: boolean;
}
) => {
const {
value,
indeterminate = false,
size = DEFAULT_SIZE,
thickness = DEFAULT_THICKNESS,
className,
style,
children,
...rest
} = props;
return (
<ArkProgress.Root
className={cn(circularProgressRootClassName, className)}
data-slot="circular-progress"
style={{ ...sizeStyle(size, thickness), ...style }}
value={indeterminate ? null : value}
{...rest}
>
{children}
<CircularProgressTrack size={size} thickness={thickness} />
</ArkProgress.Root>
);
};
export const CircularProgressRootProvider = (
props: React.ComponentProps<typeof ArkProgress.RootProvider> &
CircularProgressSizeProps
) => {
const {
size = DEFAULT_SIZE,
thickness = DEFAULT_THICKNESS,
className,
style,
children,
...rest
} = props;
return (
<ArkProgress.RootProvider
className={cn(circularProgressRootClassName, className)}
data-slot="circular-progress"
style={{ ...sizeStyle(size, thickness), ...style }}
{...rest}
>
{children}
<CircularProgressTrack size={size} thickness={thickness} />
</ArkProgress.RootProvider>
);
};
export const CircularProgressTrack = (
props: React.ComponentProps<typeof ArkProgress.Circle> &
CircularProgressSizeProps
) => {
const {
size = DEFAULT_SIZE,
thickness = DEFAULT_THICKNESS,
className,
style,
...rest
} = props;
return (
<ArkProgress.Circle
className={cn(
"block",
"pointer-events-none",
"overflow-visible",
"motion-reduce:animate-none!",
"group-data-[state=indeterminate]/circular-progress:animate-spin!",
className
)}
data-slot="circular-progress-circle"
style={{ ...sizeStyle(size, thickness), ...style }}
{...rest}
>
<ArkProgress.CircleTrack
className="stroke-input"
data-slot="circular-progress-track"
/>
<ArkProgress.CircleRange
className={cn(
"stroke-primary",
"[stroke-linecap:round]",
"transition-[stroke-dashoffset] duration-300 ease-out",
"motion-reduce:transition-none!",
"data-[state=indeterminate]:[stroke-dasharray:calc(var(--circumference)*0.3)_var(--circumference)]"
)}
data-slot="circular-progress-range"
/>
</ArkProgress.Circle>
);
};
export const CircularProgressValue = (
props: React.ComponentProps<typeof ArkProgress.ValueText>
) => {
const { className, ...rest } = props;
return (
<ArkProgress.ValueText
className={cn(
"absolute bottom-0",
"flex size-(--size) items-center justify-center",
"font-medium text-xs tabular-nums",
"pointer-events-none",
className
)}
data-slot="circular-progress-value"
{...rest}
/>
);
};
export const CircularProgressLabel = (
props: React.ComponentProps<typeof ArkProgress.Label>
) => {
const { className, ...rest } = props;
return (
<ArkProgress.Label
className={cn("font-medium text-sm", className)}
data-slot="circular-progress-label"
{...rest}
/>
);
};
export const CircularProgressView = (
props: React.ComponentProps<typeof ArkProgress.View>
) => {
const { className, ...rest } = props;
return (
<ArkProgress.View
className={cn("text-muted-foreground text-sm", className)}
data-slot="circular-progress-view"
{...rest}
/>
);
};Update the import paths to match your project setup.
Anatomy
CircularProgress
├── CircularProgressLabel
├── CircularProgressValue
├── CircularProgressView
└── CircularProgressTrack (baked in)
├── CircleTrack
└── CircleRangeCircularProgress is Ark Progress.Root. CircularProgressTrack is Progress.Circle with track and range baked in. useCircularProgress is the machine hook for CircularProgressRootProvider; useCircularProgressContext / CircularProgressContext is in-tree.
The linear bar is Progress.
Usage
import {
CircularProgress,
CircularProgressValue,
} from "@/components/ui/circular-progress";<CircularProgress value={66} />The ring is baked in. Add CircularProgressValue (and optionally CircularProgressLabel) as children.
shadcn.io size defaults to 32 and thickness to 4. Those set --size and --thickness on the root and circle. Ark’s defaultValue is 50 when you omit value.
Controlled
Use value and onValueChange. { value } is number | null.
Min and max
Default range is 0–100. Set min and max for another scale. 20 with min={10} max={30} is 50%.
Indeterminate
Set indeterminate (shadcn.io) or value={null} (Ark). The range shows a partial arc and the circle spins. Honor reduced motion: spinning is disabled.
Label
CircularProgressLabel provides extra context above the ring.
Root Provider
Use useCircularProgress with CircularProgressRootProvider when you need the API outside the tree. Pass machine options (defaultValue, min, max, formatOptions, …) to useCircularProgress(), not to the provider. Pass size / thickness to the provider (layout, not machine).
Examples
With value
Size
size is the circle width and height in pixels.
Thickness
thickness is the stroke width in pixels.
View
CircularProgressView shows children only when the progress state matches (loading, complete, or indeterminate).
Guides
Styling the circle
Size and thickness are CSS variables on the root and circle. shadcn.io size / thickness set them. You can also set the variables yourself:
[data-slot="circular-progress"] {
--size: 120px;
--thickness: 10px;
}| CSS variable | Description |
|---|---|
--size | Width and height of the circle |
--thickness | Stroke width of the track and range |
--radius | calc(var(--size) / 2 - var(--thickness) / 2) (set by the machine on the circles) |
--circumference | calc(2 * π * var(--radius)) |
--offset | Dash offset from remaining percent |
--percent | 0–100 percent of the range |
Determinate vs indeterminate
Omit indeterminate and pass a number value / defaultValue for a determinate bar. value={null} or indeterminate is the loading state (aria-valuenow omitted).
API Reference
shadcn.io wraps Ark UI Progress (circular parts). The ring is baked into CircularProgress / CircularProgressRootProvider. indeterminate is a shadcn.io convenience for value={null}. size defaults to 32 and thickness to 4.
asChild merges props onto a single child element.
CircularProgress
Root. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render the child element instead of a div. |
className | string | - | Class names on the root. |
defaultValue | number | null | 50 | Uncontrolled initial value. null is indeterminate. |
formatOptions | Intl.NumberFormatOptions | { style: "percent" } | How CircularProgressValue formats the value. |
id | string | - | Unique id for the progress machine. |
ids | Partial<{ root: string; track: string; label: string; circle: string }> | - | Element ids for composition. |
indeterminate | boolean | false | shadcn.io: sets value={null}. |
locale | string | "en-US" | Locale for formatting. Usually inherited from LocaleProvider. |
max | number | 100 | Maximum value. |
min | number | 0 | Minimum value. |
onValueChange | (details: ValueChangeDetails) => void | - | Value changed. { value: number | null }. |
orientation | "horizontal" | "vertical" | "horizontal" | Exposed as data-orientation. Little visual effect on the circle. |
size | number | 32 | Circle width and height in pixels (--size). |
thickness | number | 4 | Stroke width in pixels (--thickness). |
translations | { value?: (details: ValueTranslationDetails) => string } | see below | Accessible name / value text. Default indeterminate string is "loading...". |
value | number | null | - | Controlled value. null is indeterminate. |
Default translations.value uses the formatter (percent by default) or "loading..." when value is null.
| Attribute | Description |
|---|---|
data-slot | circular-progress |
data-scope | progress |
data-part | root |
data-max | Maximum value |
data-value | Current numeric value (omitted when indeterminate) |
data-state | "loading", "complete", or "indeterminate" |
data-orientation | "horizontal" or "vertical" |
| CSS variable | Description |
|---|---|
--percent | Percent of the range (omitted when indeterminate) |
--size | Circle size (shadcn.io) |
--thickness | Stroke width (shadcn.io) |
CircularProgressRootProvider
Takes the API from useCircularProgress. Same baked ring as CircularProgress.
| Prop | Type | Default | Description |
|---|---|---|---|
value | UseProgressReturn | required | Return value of useCircularProgress(). |
asChild | boolean | false | Render the child element instead of a div. |
className | string | - | Class names on the root. |
size | number | 32 | Circle size in pixels. |
thickness | number | 4 | Stroke width in pixels. |
Pass defaultValue, min, max, formatOptions, and other machine options to useCircularProgress(), not to the provider.
CircularProgressTrack
The SVG circle (Progress.Circle) with track and range. Baked into the root; export remains for custom composition. Renders an svg with role="progressbar".
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the svg. |
size | number | 32 | Circle size in pixels. |
thickness | number | 4 | Stroke width in pixels. |
| Attribute | Description |
|---|---|
data-slot | circular-progress-circle |
data-scope | progress |
data-part | circle |
data-state | "loading", "complete", or "indeterminate" |
data-max | Maximum value |
data-orientation | "horizontal" or "vertical" |
Track circle: data-slot="circular-progress-track", data-part="circle-track". Range circle: data-slot="circular-progress-range", data-part="circle-range", data-state as above.
aria-valuemin / aria-valuemax / aria-valuenow live on the svg. aria-valuenow is omitted when indeterminate. aria-label is the formatted value (or "loading...").
CircularProgressValue
Formatted value text (ValueText). Renders a span with aria-live="polite". Positioned over the ring (size-(--size) at the bottom of the root).
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the text. |
| Attribute | Description |
|---|---|
data-slot | circular-progress-value |
data-scope | progress |
data-part | value-text |
CircularProgressLabel
Visible label above the ring. Renders a span.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the label. |
| Attribute | Description |
|---|---|
data-slot | circular-progress-label |
data-scope | progress |
data-part | label |
data-orientation | "horizontal" or "vertical" |
CircularProgressView
Renders children only when state matches the current progress state. Renders a span.
| Prop | Type | Default | Description |
|---|---|---|---|
state | "loading" | "complete" | "indeterminate" | required | Which state this view is for. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the view. |
| Attribute | Description |
|---|---|
data-slot | circular-progress-view |
data-scope | progress |
data-part | view |
data-state | The state prop |
Linear-only parts (Track / Range as divs) belong on Progress.
useCircularProgress
Creates the progress API for CircularProgressRootProvider. Accepts the same machine options as CircularProgress except layout-only props (size, thickness, indeterminate, asChild, className).
const progress = useCircularProgress({ defaultValue: 50 });
progress.setToMax();
progress.setValue(null);CircularProgressContext / useCircularProgressContext
Render-prop or hook access. Use inside CircularProgress or CircularProgressRootProvider.
| Property | Type | Description |
|---|---|---|
value | number | null | Current value (null when indeterminate). |
valueAsString | string | Formatted value. |
percent | number | 0–100 percent of min–max. |
percentAsString | string | Formatted percent. |
min / max | number | Bounds. |
indeterminate | boolean | Whether value is null. |
setValue | (value: number | null) => void | Set the value. |
setToMax / setToMin | () => void | Jump to a bound. |
CircularProgressContext children: (context) => ReactNode.
Accessibility
Complies with the progressbar role. The SVG circle is the progressbar. Determinate values expose aria-valuemin, aria-valuemax, and aria-valuenow. Indeterminate omits aria-valuenow. CircularProgressValue uses aria-live="polite". Prefer a visible CircularProgressLabel when the percent alone is not enough context.
Keyboard support
The circular progress is not a keyboard control. It is a status indicator. Focus stays on whatever started the work (button, form, upload).