Components / Feedback
Progress Circle Circular progress on React Aria's ProgressBar. Determinate or indeterminate, in four sizes and seven colors, with the value, your own text or an icon in the middle of the ring. Respects reduced motion.
import { useEffect, useState } from "react" ;
import { ProgressCircle } from "@/components/ui/progress-circle" ;
export default function ProgressCircleDemo () {
const [ value , setValue ] = useState ( 18 );
useEffect (() => {
const id = setInterval (
() => setValue (( v ) => (v >= 100 ? 0 : Math. min ( 100 , v + 7 ))),
700 ,
);
return () => clearInterval (id);
}, []);
return (
< div className = "flex items-center gap-6" >
< ProgressCircle aria-label = "Uploading photos" value = {value} size = "lg" />
< ProgressCircle aria-label = "Syncing" isIndeterminate />
</ div >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/progress-circle
The CLI installs dependencies and any other components this one uses.
import { ProgressCircle } from "@/components/ui/progress-circle" ;
< ProgressCircle aria-label = "Uploading photos" value = { 64 } />
< ProgressCircle aria-label = "Syncing" isIndeterminate />
Also exported from progress-bar
ProgressCircle used to live in progress-bar.tsx, which still re-exports
it, so existing @/components/ui/progress-bar imports keep working.
Progress Circle: progress in a compact or square space, such as cards, dashboards, avatars and buttons, or a goal shown as a ring.
Progress Bar : progress with a visible label in forms, lists and dialogs.
Meter : a level within a range that isn't a task (storage, quota).
Spinner : plain waiting with no progress to report.
< ProgressCircle > { /* role="progressbar" */ }
{ /* svg: track circle + fill circle (aria-hidden) */ }
{ /* center: value text or children */ }
</ ProgressCircle >
Part Renders Notes ProgressCircle<div role="progressbar">React Aria ProgressBar. Sets aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext. Ring <svg aria-hidden>Track at 15% tone, fill drawn with stroke-dashoffset (500ms transition). Spins when indeterminate. Center <span>The formatted value (md and up), or children when passed.
sm (20px) for inline status next to text, md (48px, default), lg (80px) and xl (120px) for dashboards. The value is shown from md up. strokeWidth overrides the ring thickness.
import { ProgressCircle } from "@/components/ui/progress-circle" ;
export default function ProgressCircleSizes () {
return (
< div className = "flex flex-wrap items-center gap-6" >
< ProgressCircle aria-label = "Small" size = "sm" value = { 64 } />
< ProgressCircle aria-label = "Medium" size = "md" value = { 64 } />
< ProgressCircle aria-label = "Large" size = "lg" value = { 64 } />
< ProgressCircle aria-label = "Extra large" size = "xl" value = { 64 } />
</ div >
);
}
isIndeterminate spins a quarter ring and hides the value. With reduced motion, it pulses instead of spinning.
import { ProgressCircle } from "@/components/ui/progress-circle" ;
export default function ProgressCircleIndeterminate () {
return (
< div className = "flex flex-wrap items-center gap-8" >
< div className = "flex items-center gap-2 text-muted-foreground text-sm" >
< ProgressCircle aria-label = "Saving" size = "sm" isIndeterminate />
Saving changes…
</ div >
< ProgressCircle
aria-label = "Loading report"
isIndeterminate
color = "neutral"
/>
< ProgressCircle aria-label = "Processing video" size = "lg" isIndeterminate />
</ div >
);
}
color takes any tone; the track uses the same tone at 15%.
import { ProgressCircle } from "@/components/ui/progress-circle" ;
const tones = [
"brand" ,
"primary" ,
"success" ,
"info" ,
"warning" ,
"danger" ,
] as const ;
export default function ProgressCircleColors () {
return (
< div className = "flex flex-wrap items-center gap-5" >
{tones. map (( tone , i ) => (
< ProgressCircle
key = {tone}
aria-label = { `${ tone } progress` }
color = {tone}
value = { 40 + i * 10 }
/>
))}
</ div >
);
}
children replaces the value in the middle of the ring. Pass a function to read the render props (percentage, valueText, isIndeterminate). Set valueLabel so screen readers hear the same thing you show.
import { CheckIcon } from "lucide-react" ;
import { ProgressCircle } from "@/components/ui/progress-circle" ;
export default function ProgressCircleCustomContent () {
return (
< div className = "flex flex-wrap items-center gap-8" >
< ProgressCircle
aria-label = "Weekly workouts"
size = "xl"
value = { 6 }
maxValue = { 8 }
valueLabel = "6 of 8 workouts"
color = "success"
>
< span className = "font-semibold text-2xl tabular-nums" >6/8</ span >
< span className = "text-muted-foreground text-xs" >workouts</ span >
</ ProgressCircle >
< ProgressCircle
aria-label = "Storage used"
size = "xl"
value = { 7.4 }
maxValue = { 10 }
valueLabel = "7.4 of 10 GB"
strokeWidth = { 12 }
>
{({ percentage }) => (
<>
< span className = "font-semibold text-xl tabular-nums" >
{Math. round (percentage ?? 0 )}%
</ span >
< span className = "text-muted-foreground text-xs" >of 10 GB</ span >
</>
)}
</ ProgressCircle >
< ProgressCircle
aria-label = "Profile complete"
size = "lg"
value = { 100 }
color = "success"
>
< CheckIcon className = "size-6 text-success" strokeWidth = { 2.5 } />
</ ProgressCircle >
</ div >
);
}
Small circles next to text, each labelled by its heading with aria-labelledby, and maxValue set to the goal so the ring maps to real units.
82%
Activation
412 of 500 new teams
87%
Retention
Week-4 retention 61% · target 70%
100%
NPS responses
1,240 of 1,000 collected
import { useId } from "react" ;
import { ProgressCircle } from "@/components/ui/progress-circle" ;
const goals = [
{
name: "Activation" ,
detail: "412 of 500 new teams" ,
value: 412 ,
max: 500 ,
color: "brand" ,
},
{
name: "Retention" ,
detail: "Week-4 retention 61% · target 70%" ,
value: 61 ,
max: 70 ,
color: "info" ,
},
{
name: "NPS responses" ,
detail: "1,240 of 1,000 collected" ,
value: 1240 ,
max: 1000 ,
color: "success" ,
},
] as const ;
function Goal ({ g } : { g : ( typeof goals)[ number ] }) {
const id = useId ();
return (
< li className = "flex items-center gap-3 rounded-lg border bg-card p-3" >
< ProgressCircle
aria-labelledby = {id}
value = {Math. min (g.value, g.max)}
maxValue = {g.max}
color = {g.color}
/>
< div className = "min-w-0" >
< p id = {id} className = "font-medium text-sm" >
{g.name}
</ p >
< p className = "truncate text-muted-foreground text-xs" >{g.detail}</ p >
</ div >
</ li >
);
}
export default function ProgressCircleRecipeGoals () {
return (
< ul className = "grid w-full max-w-md gap-2" >
{goals. map (( g ) => (
< Goal key = {g.name} g = {g} />
))}
</ ul >
);
}
Renders role="progressbar" with aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext. Indeterminate circles omit the value.
There's no label prop, so always pass aria-label or aria-labelledby.
aria-valuetext is your valueLabel, or the value formatted with formatOptions. Custom center content is visual only, so keep valueLabel in sync with it.
The SVG is aria-hidden.
Respects prefers-reduced-motion: the fill jumps to each value, and the indeterminate ring pulses instead of spinning.
Progress circles aren't live regions. To announce completion, update a role="status" element.
Slot Element data-slot="progress-circle"Root data-slot="progress-circle-label"Center content
color sets --tone on the root. The track uses stroke-(--tone)/15 and the fill stroke-(--tone).
Also accepts every prop of React Aria's ProgressBar .
Progress Bar : the same information as a bar, with a label.
Spinner : waiting without progress.
Meter : a level within a range.
PreviousMeter Next Skeleton