Circular Progress
A circular progress indicator that displays completion progress in a ring format with support for indeterminate states.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
CircularProgress,
CircularProgressIndicator,
CircularProgressRange,
CircularProgressTrack,
CircularProgressValueText,
} from "@/components/ui/circular-progress";
export function CircularProgressDemo() {
const [value, setValue] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(() => {
setValue((prev) => {
if (prev >= 100) {
clearInterval(interval);
return 100;
}
return prev + 2;
});
}, 150);
return () => clearInterval(interval);
}, []);
return (
<CircularProgress value={value} size={60}>
<CircularProgressIndicator>
<CircularProgressTrack />
<CircularProgressRange />
</CircularProgressIndicator>
<CircularProgressValueText />
</CircularProgress>
);
}
Installation
CLI
npx shadcn@latest add "https://diceui.com/r/circular-progress"
Manual
Install the following dependencies:
npm install @radix-ui/react-slot
Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { cn } from "@/lib/utils";
const CIRCULAR_PROGRESS_NAME = "CircularProgress";
const INDICATOR_NAME = "CircularProgressIndicator";
const TRACK_NAME = "CircularProgressTrack";
const RANGE_NAME = "CircularProgressRange";
const VALUE_TEXT_NAME = "CircularProgressValueText";
const DEFAULT_MAX = 100;
type ProgressState = "indeterminate" | "complete" | "loading";
function getProgressState(
value: number | undefined | null,
maxValue: number,
): ProgressState {
return value == null
? "indeterminate"
: value === maxValue
? "complete"
: "loading";
}
function getIsValidNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function getIsValidMaxNumber(max: unknown): max is number {
return getIsValidNumber(max) && max > 0;
}
function getIsValidValueNumber(
value: unknown,
min: number,
max: number,
): value is number {
return getIsValidNumber(value) && value <= max && value >= min;
}
function getDefaultValueText(value: number, min: number, max: number): string {
const percentage = max === min ? 100 : ((value - min) / (max - min)) * 100;
return `${Math.round(percentage)}%`;
}
function getInvalidValueError(
propValue: string,
componentName: string,
): string {
return `Invalid prop \`value\` of value \`${propValue}\` supplied to \`${componentName}\`. The \`value\` prop must be a number between \`min\` and \`max\` (inclusive), or \`null\`/\`undefined\` for indeterminate progress. The value will be clamped to the valid range.`;
}
function getInvalidMaxError(propValue: string, componentName: string): string {
return `Invalid prop \`max\` of value \`${propValue}\` supplied to \`${componentName}\`. Only numbers greater than 0 are valid. Defaulting to ${DEFAULT_MAX}.`;
}
interface CircularProgressContextValue {
value: number | null;
valueText: string | undefined;
max: number;
min: number;
state: ProgressState;
radius: number;
thickness: number;
size: number;
center: number;
circumference: number;
percentage: number | null;
valueTextId?: string;
}
const CircularProgressContext =
React.createContext<CircularProgressContextValue | null>(null);
function useCircularProgressContext(consumerName: string) {
const context = React.useContext(CircularProgressContext);
if (!context) {
throw new Error(
`\`${consumerName}\` must be used within \`${CIRCULAR_PROGRESS_NAME}\``,
);
}
return context;
}
interface CircularProgressRootProps extends React.ComponentProps<"div"> {
value?: number | null | undefined;
getValueText?(value: number, min: number, max: number): string;
min?: number;
max?: number;
size?: number;
thickness?: number;
label?: string;
asChild?: boolean;
}
function CircularProgressRoot(props: CircularProgressRootProps) {
const {
value: valueProp = null,
getValueText = getDefaultValueText,
min: minProp = 0,
max: maxProp,
size = 48,
thickness = 4,
label,
asChild,
className,
children,
...progressProps
} = props;
if ((maxProp || maxProp === 0) && !getIsValidMaxNumber(maxProp)) {
if (process.env.NODE_ENV !== "production") {
console.error(getInvalidMaxError(`${maxProp}`, CIRCULAR_PROGRESS_NAME));
}
}
const rawMax = getIsValidMaxNumber(maxProp) ? maxProp : DEFAULT_MAX;
const min = getIsValidNumber(minProp) ? minProp : 0;
const max = rawMax <= min ? min + 1 : rawMax;
if (process.env.NODE_ENV !== "production" && thickness >= size) {
console.warn(
`CircularProgress: thickness (${thickness}) should be less than size (${size}) for proper rendering.`,
);
}
if (valueProp !== null && !getIsValidValueNumber(valueProp, min, max)) {
if (process.env.NODE_ENV !== "production") {
console.error(
getInvalidValueError(`${valueProp}`, CIRCULAR_PROGRESS_NAME),
);
}
}
const value = getIsValidValueNumber(valueProp, min, max)
? valueProp
: getIsValidNumber(valueProp) && valueProp > max
? max
: getIsValidNumber(valueProp) && valueProp < min
? min
: null;
const valueText = getIsValidNumber(value)
? getValueText(value, min, max)
: undefined;
const state = getProgressState(value, max);
const radius = Math.max(0, (size - thickness) / 2);
const center = size / 2;
const circumference = 2 * Math.PI * radius;
const percentage = getIsValidNumber(value)
? max === min
? 1
: (value - min) / (max - min)
: null;
const labelId = React.useId();
const valueTextId = React.useId();
const contextValue = React.useMemo<CircularProgressContextValue>(
() => ({
value,
valueText,
max,
min,
state,
radius,
thickness,
size,
center,
circumference,
percentage,
valueTextId,
}),
[
value,
valueText,
max,
min,
state,
radius,
thickness,
size,
center,
circumference,
percentage,
valueTextId,
],
);
const RootPrimitive = asChild ? Slot : "div";
return (
<CircularProgressContext.Provider value={contextValue}>
<RootPrimitive
role="progressbar"
aria-describedby={valueText ? valueTextId : undefined}
aria-labelledby={labelId}
aria-valuemax={max}
aria-valuemin={min}
aria-valuenow={getIsValidNumber(value) ? value : undefined}
aria-valuetext={valueText}
data-state={state}
data-value={value ?? undefined}
data-max={max}
data-min={min}
data-percentage={percentage}
{...progressProps}
className={cn(
"relative inline-flex w-fit items-center justify-center",
className,
)}
>
{children}
{label && <label id={labelId}>{label}</label>}
</RootPrimitive>
</CircularProgressContext.Provider>
);
}
function CircularProgressIndicator(props: React.ComponentProps<"svg">) {
const { className, ...indicatorProps } = props;
const context = useCircularProgressContext(INDICATOR_NAME);
return (
<svg
aria-hidden="true"
focusable="false"
viewBox={`0 0 ${context.size} ${context.size}`}
data-state={context.state}
data-value={context.value ?? undefined}
data-max={context.max}
data-min={context.min}
data-percentage={context.percentage}
width={context.size}
height={context.size}
{...indicatorProps}
className={cn("-rotate-90 transform", className)}
/>
);
}
CircularProgressIndicator.displayName = INDICATOR_NAME;
function CircularProgressTrack(props: React.ComponentProps<"circle">) {
const { className, ...trackProps } = props;
const context = useCircularProgressContext(TRACK_NAME);
return (
<circle
data-state={context.state}
cx={context.center}
cy={context.center}
r={context.radius}
fill="none"
stroke="currentColor"
strokeWidth={context.thickness}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
{...trackProps}
className={cn("text-muted-foreground/20", className)}
/>
);
}
function CircularProgressRange(props: React.ComponentProps<"circle">) {
const { className, ...rangeProps } = props;
const context = useCircularProgressContext(RANGE_NAME);
const strokeDasharray = context.circumference;
const strokeDashoffset =
context.state === "indeterminate"
? context.circumference * 0.75
: context.percentage !== null
? context.circumference - context.percentage * context.circumference
: context.circumference;
return (
<circle
data-state={context.state}
data-value={context.value ?? undefined}
data-max={context.max}
data-min={context.min}
cx={context.center}
cy={context.center}
r={context.radius}
fill="none"
stroke="currentColor"
strokeWidth={context.thickness}
strokeLinecap="round"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
vectorEffect="non-scaling-stroke"
{...rangeProps}
className={cn(
"origin-center text-primary transition-all duration-300 ease-in-out",
context.state === "indeterminate" &&
"motion-reduce:animate-none motion-safe:[animation:var(--animate-spin-around)]",
className,
)}
/>
);
}
interface CircularProgressValueTextProps extends React.ComponentProps<"span"> {
asChild?: boolean;
}
function CircularProgressValueText(props: CircularProgressValueTextProps) {
const { asChild, className, children, ...valueTextProps } = props;
const context = useCircularProgressContext(VALUE_TEXT_NAME);
const ValueTextPrimitive = asChild ? Slot : "span";
return (
<ValueTextPrimitive
id={context.valueTextId}
data-state={context.state}
{...valueTextProps}
className={cn(
"absolute inset-0 flex items-center justify-center font-medium text-sm",
className,
)}
>
{children ?? context.valueText}
</ValueTextPrimitive>
);
}
function CircularProgressCombined(props: CircularProgressRootProps) {
return (
<CircularProgressRoot {...props}>
<CircularProgressIndicator>
<CircularProgressTrack />
<CircularProgressRange />
</CircularProgressIndicator>
<CircularProgressValueText />
</CircularProgressRoot>
);
}
export {
CircularProgressRoot as Root,
CircularProgressIndicator as Indicator,
CircularProgressTrack as Track,
CircularProgressRange as Range,
CircularProgressValueText as ValueText,
CircularProgressCombined as Combined,
//
CircularProgressRoot as CircularProgress,
CircularProgressIndicator,
CircularProgressTrack,
CircularProgressRange,
CircularProgressValueText,
CircularProgressCombined,
};
Layout
Import the parts and compose them together.
import {
CircularProgress,
CircularProgressIndicator,
CircularProgressRange,
CircularProgressTrack,
CircularProgressValueText,
} from "@/components/ui/circular-progress";
<CircularProgress>
<CircularProgressIndicator>
<CircularProgressTrack />
<CircularProgressRange />
</CircularProgressIndicator>
<CircularProgressValueText />
</CircularProgress>
Or use the Combined
component to get all the parts in one.
import { CircularProgressCombined } from "@/registry/default/ui/circular-progress";
<CircularProgressCombined />
Examples
Interactive Demo
A circular progress with interactive controls and simulated upload progress.
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
CircularProgress,
CircularProgressIndicator,
CircularProgressRange,
CircularProgressTrack,
CircularProgressValueText,
} from "@/components/ui/circular-progress";
export function CircularProgressControlledDemo() {
const [uploadProgress, setUploadProgress] = React.useState<number | null>(0);
const [isUploading, setIsUploading] = React.useState(false);
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const onUploadStart = React.useCallback(() => {
setIsUploading(true);
setUploadProgress(0);
}, []);
const onUploadReset = React.useCallback(() => {
setUploadProgress(0);
setIsUploading(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);
React.useEffect(() => {
if (isUploading) {
intervalRef.current = setInterval(() => {
setUploadProgress((prev) => {
if (prev === null) return 0;
if (prev >= 100) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsUploading(false);
return 100;
}
return Math.min(100, prev + Math.random() * 15);
});
}, 200);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [isUploading]);
return (
<div className="flex flex-col items-center gap-6">
<div className="flex items-center gap-6">
<CircularProgress
value={uploadProgress}
min={0}
max={100}
size={80}
thickness={6}
>
<CircularProgressIndicator>
<CircularProgressTrack />
<CircularProgressRange />
</CircularProgressIndicator>
<CircularProgressValueText className="font-semibold text-base" />
</CircularProgress>
<div className="flex flex-col gap-2">
<div className="font-medium text-sm">Upload Progress</div>
<div className="text-muted-foreground text-xs">
Status:{" "}
{isUploading
? "Uploading..."
: uploadProgress === 100
? "Complete"
: "Ready"}
</div>
<div className="text-muted-foreground text-xs">
Progress:{" "}
{uploadProgress === null
? "Indeterminate"
: `${Math.round(uploadProgress)}%`}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={onUploadStart} disabled={isUploading}>
Start upload
</Button>
<Button size="sm" onClick={onUploadReset} disabled={isUploading}>
Reset
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setUploadProgress(null)}
disabled={isUploading}
>
Indeterminate
</Button>
</div>
</div>
);
}
Themes
Different color themes using Tailwind CSS stroke and text utilities to customize the track, range, and value text colors.
"use client";
import { motion, useInView, useMotionValue, useSpring } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
CircularProgress,
CircularProgressIndicator,
CircularProgressRange,
CircularProgressTrack,
CircularProgressValueText,
} from "@/components/ui/circular-progress";
const themes = [
{
name: "Default",
trackClass: "text-muted-foreground/20",
rangeClass: "text-primary",
textClass: "text-foreground",
},
{
name: "Success",
trackClass: "text-green-200 dark:text-green-900",
rangeClass: "text-green-500",
textClass: "text-green-700 dark:text-green-300",
},
{
name: "Warning",
trackClass: "text-yellow-200 dark:text-yellow-900",
rangeClass: "text-yellow-500",
textClass: "text-yellow-700 dark:text-yellow-300",
},
{
name: "Destructive",
trackClass: "text-red-200 dark:text-red-900",
rangeClass: "text-red-500",
textClass: "text-red-700 dark:text-red-300",
},
{
name: "Purple",
trackClass: "text-purple-200 dark:text-purple-900",
rangeClass: "text-purple-500",
textClass: "text-purple-700 dark:text-purple-300",
},
{
name: "Orange",
trackClass: "text-orange-200 dark:text-orange-900",
rangeClass: "text-orange-500",
textClass: "text-orange-700 dark:text-orange-300",
},
{
name: "Blue",
trackClass: "text-blue-200 dark:text-blue-900",
rangeClass: "text-blue-500",
textClass: "text-blue-700 dark:text-blue-300",
},
{
name: "Pink",
trackClass: "text-pink-200 dark:text-pink-900",
rangeClass: "text-pink-500",
textClass: "text-pink-700 dark:text-pink-300",
},
];
export function CircularProgressThemesDemo() {
return (
<>
<div className="hidden grid-cols-4 gap-4 sm:grid">
{themes.map((theme, index) => (
<AnimatedCircularProgress
key={theme.name}
theme={theme}
index={index}
/>
))}
</div>
<div className="grid grid-cols-2 gap-4 sm:hidden">
{themes.slice(0, 4).map((theme, index) => (
<AnimatedCircularProgress
key={theme.name}
theme={theme}
index={index}
/>
))}
</div>
</>
);
}
interface AnimatedCircularProgressProps {
theme: (typeof themes)[0];
index: number;
}
function AnimatedCircularProgress({
theme,
index,
}: AnimatedCircularProgressProps) {
const ref = React.useRef(null);
const isInView = useInView(ref, { once: true, margin: "-100px" });
const motionValue = useMotionValue(0);
const springValue = useSpring(motionValue, {
stiffness: 60,
damping: 15,
mass: 1,
});
const [displayValue, setDisplayValue] = React.useState(0);
React.useEffect(() => {
if (isInView) {
const delay = index * 150;
const timer = setTimeout(() => {
motionValue.set(75);
}, delay);
return () => clearTimeout(timer);
}
}, [isInView, motionValue, index]);
React.useEffect(() => {
const unsubscribe = springValue.on("change", (latest) => {
setDisplayValue(Math.round(latest));
});
return unsubscribe;
}, [springValue]);
return (
<motion.div
ref={ref}
className="flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{
duration: 0.6,
delay: index * 0.1,
ease: [0.21, 1.11, 0.81, 0.99],
}}
>
<CircularProgress value={displayValue} size={80} thickness={6}>
<CircularProgressIndicator>
<CircularProgressTrack className={theme.trackClass} />
<CircularProgressRange className={theme.rangeClass} />
</CircularProgressIndicator>
<CircularProgressValueText
className={cn("font-semibold text-sm", theme.textClass)}
/>
</CircularProgress>
<div className="flex flex-col items-center gap-1 text-center">
<h4 className="font-medium text-sm">{theme.name}</h4>
<p className="text-muted-foreground text-xs">
{displayValue}% complete
</p>
</div>
</motion.div>
);
}
Theming
The circular progress component uses CSS currentColor
for stroke colors, making it easy to theme using Tailwind's text or stroke utilities:
Track Theming
<CircularProgressTrack className="text-green-200 dark:text-green-900" />
Range Theming
<CircularProgressRange className="text-green-500" />
Value Text Theming
<CircularProgressValueText className="text-green-700 dark:text-green-300" />
Custom Stroke Styles
You can also use Tailwind's stroke utilities directly:
<CircularProgressTrack className="stroke-blue-200" />
<CircularProgressRange className="stroke-blue-500" />
API Reference
Root
The main container component for the circular progress.
Prop
Type
Data Attribute | Value |
---|---|
[data-state] | The current state of the progress: 'indeterminate', 'loading', or 'complete'. |
[data-value] | The current progress value (only present when not indeterminate). |
[data-max] | The maximum progress value. |
[data-min] | The minimum progress value. |
[data-percentage] | The normalized progress value as a decimal between 0 and 1 (only present when not indeterminate). |
Indicator
The SVG container that holds the circular progress tracks and ranges.
Prop
Type
Data Attribute | Value |
---|---|
[data-state] | The current state of the progress: 'indeterminate', 'loading', or 'complete'. |
[data-value] | The current progress value (only present when not indeterminate). |
[data-max] | The maximum progress value. |
[data-min] | The minimum progress value. |
[data-percentage] | The normalized progress value as a decimal between 0 and 1 (only present when not indeterminate). |
Track
The background circle that represents the full range of possible values.
Prop
Type
Data Attribute | Value |
---|---|
[data-state] | The current state of the progress: 'indeterminate', 'loading', or 'complete'. |
Range
The portion of the circle that represents the current progress value.
Prop
Type
Data Attribute | Value |
---|---|
[data-state] | The current state of the progress: 'indeterminate', 'loading', or 'complete'. |
[data-value] | The current progress value (only present when not indeterminate). |
[data-max] | The maximum progress value. |
[data-min] | The minimum progress value. |
ValueText
The text element that displays the current progress value or custom content.
Prop
Type
Data Attribute | Value |
---|---|
[data-state] | The current state of the progress: 'indeterminate', 'loading', or 'complete'. |
Combined
The combined component that includes all the parts.
Prop
Type
Accessibility
Screen Reader Support
- Uses the
progressbar
role for proper screen reader identification - Provides
aria-valuemin
,aria-valuemax
,aria-valuenow
, andaria-valuetext
attributes - Supports indeterminate state by omitting
aria-valuenow
when value is null
Notes
- The component automatically handles indeterminate states when
value
isnull
orundefined
- Progress values are automatically clamped to the valid range between
min
andmax
- Invalid
max
orvalue
props will log console errors and use fallback values - The indeterminate animation uses CSS custom properties and can be customized via the
--animate-spin-around
variable - All stroke colors use
currentColor
by default, making them responsive to text color changes