Progress
A progress bar, and the same progress as a ring, for showing completion toward a task.
Installation
Usage
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
16, 24, and 48px. The stroke is an eighth of the diameter at every one, so all three read as the same ring rather than three different weights, and that holds if you size it past these three with a className.
Ring weights
The eighth-of-diameter stroke is tuned to the shipped 16-48px range; perceived weight grows with area, so a ring sized well past that reads the same ratio as a donut. weight="thin" halves the stroke to a sixteenth for exactly that case, so a bigger ring stays this component instead of a hand-rolled fork.
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 }