Dice UI
Components

Circular Progress

A circular progress indicator that displays completion progress in a ring format with support for indeterminate states.

API
"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 AttributeValue
[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 AttributeValue
[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 AttributeValue
[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 AttributeValue
[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 AttributeValue
[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, and aria-valuetext attributes
  • Supports indeterminate state by omitting aria-valuenow when value is null

Notes

  • The component automatically handles indeterminate states when value is null or undefined
  • Progress values are automatically clamped to the valid range between min and max
  • Invalid max or value 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