Progress
A progress bar, and the same progress as a ring, for showing completion toward a task.
Installation
npx shadcn@latest add https://ui.mattdowney.com/r/progress.jsonUsage
import { Progress, ProgressRing } from "@/components/ui/progress"Examples
Default
The indicator eases to its value (300ms ease-out). Remount via key to replay the fill from zero.
Ring
The same progress as a ring, for the places a full-width bar does not fit: a card corner, a row, a button's neighbor. It eases to its value the same way the bar does.
Ring sizes
Three sizes: 16, 24, and 48px. For anything larger, set the size with a className and pair it with the thin weight.
Ring weights
weight="thin" halves the stroke. Use it on a ring much larger than 48px, where the default stroke starts to look like a donut.
Ring with a value
Children sit in the middle of the ring, out of the flow, so the box stays the size it was asked for. lg is sized for exactly this: a smaller ring leaves the label touching the arc.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | null | - | Current progress between min and max. On the bar, omitting it (or passing null) is the indeterminate state: a pulsing one-third fill instead of an empty track. The ring has no such state and draws an empty track instead, because a ring is a quantity and a spinning one is a waiting state, which is a different thing. The bar's fill respects min/max and flips edges automatically in RTL. |
| sizeProgressRing | "sm" | "default" | "lg" | "default" | Ring diameter: 16, 24, or 48px. The stroke is a fixed ratio of it, so the three read as one ring. lg is sized to hold a number in the middle. |
| weightProgressRing | "default" | "thin" | "default" | Stroke as a ratio of the diameter: an eighth, or a sixteenth for thin. Use thin when sizing the ring past the shipped three with a className, where the default ratio reads as a donut. |
| childrenProgressRing | ReactNode | - | Content centered inside the ring, e.g. the percentage. Absolutely placed, so it never changes the ring's own size. |
| min | number | 0 | Lower bound of the range. |
| max | number | 100 | The value at which the bar reads full. Drives both the visual fill and the value reported to assistive tech. |
| getValueLabel | (value, max) => string | - | Overrides the accessible label announced for the current value. |
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/registry/lib/utils"
// @use-when how far along a determinate operation is.
function Progress({
className,
value,
max = 100,
...props
}: Omit<React.ComponentProps<typeof ProgressPrimitive.Root>, "value"> & {
value?: number | null
}) {
return (
<ProgressPrimitive.Root
data-slot="progress"
value={value ?? null}
max={max}
className={cn(
"bg-neutral-200 dark:bg-neutral-800 relative h-1.5 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
{/* Base UI sizes the indicator (insetInlineStart + width from value/min/max),
so no inline style here; overriding it once broke min, indeterminate, and RTL all at once. */}
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
// `ease-in-out`, not `ease-out`: the bar is already on screen and
// travels, so it needs to accelerate as well as settle. `ease-out`
// belongs to things entering or leaving.
className="bg-accent h-full transition-[width] duration-300 ease-in-out motion-reduce:transition-none data-[indeterminate]:w-1/3 data-[indeterminate]:animate-pulse motion-reduce:data-[indeterminate]:animate-none"
/>
</ProgressPrimitive.Root>
)
}
type ProgressRingSize = "sm" | "default" | "lg"
type ProgressRingWeight = "default" | "thin"
// 16 / 24 / 48. `lg` is the one that carries a number in the middle, and it is
// sized for that: at 40 the stroke is 5 and the inner circle is 30, which a
// two-digit percentage crosses corner to corner with about two pixels to
// spare, so the label reads as jammed into the ring rather than sitting in it.
const RING_SIZE_CLASS: Record<ProgressRingSize, string> = {
sm: "size-4",
default: "size-6",
lg: "size-12",
}
// ONE geometry per weight, drawn in a 32-unit box and scaled by the class
// above. The stroke is a ratio of the diameter, which is the same argument the
// action radius ramp makes: a ring is read as a ratio, so a stroke fixed in
// pixels turns a 16px ring into a donut and a 40px one into a hairline. Because
// the viewBox scales, that ratio survives a consumer sizing this with their own
// `className` too.
//
// The ratio itself is tuned to a RANGE, though, not universal: an eighth is
// right for the 16-48px rings the kit ships, but perceived weight grows with
// area, so a ring sized well past those reads the same ratio as a donut (a
// 40px+ ring at an eighth is a 5px band). `thin` halves it to a sixteenth for
// exactly that case; it exists so a bigger ring stays this component instead
// of a hand-rolled fork.
const RING_BOX = 32
const RING_WEIGHT_STROKE: Record<ProgressRingWeight, number> = {
default: RING_BOX / 8,
thin: RING_BOX / 16,
}
function ProgressRing({
className,
value,
min = 0,
max = 100,
size = "default",
weight = "default",
children,
...props
}: Omit<React.ComponentProps<typeof ProgressPrimitive.Root>, "value"> & {
value?: number | null
size?: ProgressRingSize
weight?: ProgressRingWeight
}) {
const stroke = RING_WEIGHT_STROKE[weight]
const radius = (RING_BOX - stroke) / 2
const circumference = 2 * Math.PI * radius
// A ring is a QUANTITY, not a wait. No value draws an empty track and stays
// still: a spinning arc is a different component with a different meaning,
// and the two are only a shape apart, which is exactly why one must not
// quietly become the other.
const fraction =
value === null || value === undefined
? 0
: Math.min(1, Math.max(0, (value - min) / (max - min || 1)))
return (
<ProgressPrimitive.Root
data-slot="progress-ring"
value={value ?? null}
min={min}
max={max}
className={cn("relative inline-flex shrink-0", RING_SIZE_CLASS[size], className)}
{...props}
>
<svg viewBox={`0 0 ${RING_BOX} ${RING_BOX}`} className="size-full" aria-hidden="true">
{/* One step in from the bar's track on each side (200/800), not the
same rung and not the mid one. A ring is a 2px line where the bar is
6px of fill, so it needs a little more contrast to read at the same
WEIGHT: that is an optical correction, and it is one step, not
three. A mid grey was tried and is too heavy in light mode, where it
competes with the arc it is supposed to sit behind. */}
<circle
cx={RING_BOX / 2}
cy={RING_BOX / 2}
r={radius}
fill="none"
strokeWidth={stroke}
className="stroke-neutral-300 dark:stroke-neutral-700"
/>
<circle
cx={RING_BOX / 2}
cy={RING_BOX / 2}
r={radius}
fill="none"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - fraction)}
// From twelve o'clock. An SVG circle starts at three, and progress
// that starts on the right edge reads as already underway.
transform={`rotate(-90 ${RING_BOX / 2} ${RING_BOX / 2})`}
// Same easing as the bar, for the same reason: the arc is already on
// screen and travels, so it accelerates as well as settles.
className="stroke-accent transition-[stroke-dashoffset] duration-300 ease-in-out motion-reduce:transition-none"
/>
</svg>
{children !== undefined && (
// The number goes in the middle, absolutely placed rather than in the
// flow, so the ring's own box stays exactly the size it was asked for.
<span className="absolute inset-0 flex items-center justify-center text-2xs font-medium tabular-nums">
{children}
</span>
)}
</ProgressPrimitive.Root>
)
}
export { Progress, ProgressRing }