my/ui

Command Palette

Search for a command to run...

All components

Circular Progress Bar

loaders

An SVG circular gauge with a primary arc (filled) and a secondary arc (gap indicator), both driven by CSS stroke-dasharray transitions. Accepts min/max/value and custom stroke colors.

responsive · 600px

Install

Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:

$npx shadcn@latest add https://your-domain/r/animated-circular-progressbar.json

Usage

"use client"

import { useEffect, useState } from "react"
import { AnimatedCircularProgressBar } from "@/registry/inspira-react/animated-circular-progressbar"

export default function AnimatedCircularProgressBarDemo() {
  const [value, setValue] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      setValue((prev) => {
        if (prev >= 100) return 0
        return prev + 5
      })
    }, 300)
    return () => clearInterval(timer)
  }, [])

  return (
    <div className="flex min-h-[300px] flex-wrap items-center justify-center gap-8 p-8">
      <AnimatedCircularProgressBar
        value={value}
        max={100}
        gaugePrimaryColor="rgb(79 70 229)"
        gaugeSecondaryColor="rgba(0, 0, 0, 0.1)"
        duration={0.3}
      />
      <AnimatedCircularProgressBar
        value={65}
        max={100}
        gaugePrimaryColor="rgb(16 185 129)"
        gaugeSecondaryColor="rgba(0, 0, 0, 0.1)"
        duration={1}
      />
      <AnimatedCircularProgressBar
        value={30}
        max={100}
        gaugePrimaryColor="rgb(245 158 11)"
        gaugeSecondaryColor="rgba(0, 0, 0, 0.1)"
        circleStrokeWidth={8}
        duration={1}
      />
    </div>
  )
}

Component source

"use client"

import { cn } from "@/lib/utils"

interface AnimatedCircularProgressBarProps {
  max?: number
  value?: number
  min?: number
  gaugePrimaryColor?: string
  gaugeSecondaryColor?: string
  className?: string
  circleStrokeWidth?: number
  showPercentage?: boolean
  duration?: number
}

export function AnimatedCircularProgressBar({
  max = 100,
  value = 0,
  min = 0,
  gaugePrimaryColor = "rgb(79 70 229)",
  gaugeSecondaryColor = "rgba(0, 0, 0, 0.1)",
  className,
  circleStrokeWidth = 10,
  showPercentage = true,
  duration = 1,
}: AnimatedCircularProgressBarProps) {
  const circumference = 2 * Math.PI * 45
  const percentPx = circumference / 100
  const currentPercent = ((value - min) / (max - min)) * 100

  const primaryDashArray = `${currentPercent * percentPx}px ${circumference}px`
  // Secondary arc covers the gap (90 - currentPercent)
  const secondaryPercent = Math.max(0, 90 - currentPercent)
  const secondaryDashArray = `${secondaryPercent * percentPx}px ${circumference}px`

  // Primary starts at -90deg
  const primaryRotation = -90
  // Secondary is inverted, starts from the tail end
  const secondaryRotation = `rotate(calc(1turn - 90deg))`

  return (
    <div
      className={cn("relative size-40 text-2xl font-semibold", className)}
      style={
        {
          "--circumference": circumference,
          "--percent-to-px": `${percentPx}px`,
          "--gap-percent": 5,
          "--percent-to-deg": "3.6deg",
          "--duration": `${duration}s`,
          "--primary-color": gaugePrimaryColor,
          "--secondary-color": gaugeSecondaryColor,
        } as React.CSSProperties
      }
    >
      <svg
        fill="none"
        className="size-full"
        strokeWidth="2"
        viewBox="0 0 100 100"
      >
        {currentPercent <= 90 && currentPercent >= 0 && (
          <circle
            cx="50"
            cy="50"
            r="45"
            strokeWidth={circleStrokeWidth}
            strokeDashoffset="0"
            strokeLinecap="round"
            strokeLinejoin="round"
            stroke={gaugeSecondaryColor}
            fill="none"
            style={{
              strokeDasharray: secondaryDashArray,
              transform: secondaryRotation,
              transformOrigin: "50px 50px",
              transition: `stroke-dasharray ${duration}s ease`,
            }}
          />
        )}
        <circle
          cx="50"
          cy="50"
          r="45"
          strokeWidth={circleStrokeWidth}
          strokeDashoffset="0"
          strokeLinecap="round"
          strokeLinejoin="round"
          stroke={gaugePrimaryColor}
          fill="none"
          style={{
            strokeDasharray: primaryDashArray,
            transform: `rotate(${primaryRotation}deg)`,
            transformOrigin: "50px 50px",
            transition: `stroke-dasharray ${duration}s ease`,
          }}
        />
      </svg>
      {showPercentage && (
        <span className="absolute inset-0 m-auto flex size-fit items-center justify-center">
          {Math.round(currentPercent)}%
        </span>
      )}
    </div>
  )
}