Skip to content
goodtabs UI
BrowseBrowse components

Progress ring

A circular progress indicator drawn with one circle and two numbers.

npx shadcn@latest add https://ui.goodtabs.co/r/progress-ring.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/progress-ring

70%

A 72px ring at 70 percent with the value in the centre.

Showing the working

70%
  • r = (24 - 2) / 2 = 11
  • 2πr = 69.12
  • offset at 70% = 20.73

A 24px ring beside the numbers that draw it.

Quarters

25%
50%
75%
100%

The same ring at 25, 50, 75 and 100 percent.

Usage

import { ProgressRing } from "@/components/ui/progress-ring"

<ProgressRing label="Upload progress" value={70} />

Props

PropTypeDefaultDescription
labelstringAccessible name for the progressbar.
valuenumberCurrent value.
maxnumber100The value that means done.
sizenumber64Outer diameter in px.
strokenumber4Stroke width in px.
childrenReactNodeCentre content. Defaults to the percentage.

Source

progress-ring/progress-ring.tsx
import * as React from "react";
import { cn } from "@/lib/utils";

type ProgressRingProps = Omit<React.ComponentProps<"div">, "role"> & {
  /** Accessible name for the progressbar. */
  label: string;
  /** Current value. */
  value: number;
  /** The value that means done. */
  max?: number;
  /** Outer diameter in px. */
  size?: number;
  /** Stroke width in px. The radius is (size - stroke) / 2 so the stroke stays inside the box. */
  stroke?: number;
};

/**
 * A progress ring drawn with one circle. dasharray is the circumference 2πr and
 * dashoffset is 2πr(1 - p), so the ring fills clockwise from the top. Children sit in the
 * centre.
 */
export function ProgressRing({
  label,
  value,
  max = 100,
  size = 64,
  stroke = 4,
  className,
  children,
  ...props
}: ProgressRingProps) {
  const r = (size - stroke) / 2;
  const c = 2 * Math.PI * r;
  const p = max > 0 ? Math.min(Math.max(value / max, 0), 1) : 0;

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-valuenow={value}
      className={cn("relative inline-grid place-items-center", className)}
      style={{ width: size, height: size }}
      {...props}
    >
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" className="-rotate-90">
        <circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-foreground/12" />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={r}
          fill="none"
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={c}
          strokeDashoffset={c * (1 - p)}
          className="stroke-foreground transition-[stroke-dashoffset] duration-200 ease-out motion-reduce:transition-none"
        />
      </svg>
      <div className="absolute inset-0 grid place-items-center font-mono text-xs text-foreground tabular-nums">
        {children ?? `${Math.round(p * 100)}%`}
      </div>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/progress-ring/prompt.md, or the Copy AI prompt button above.

When to use

  • Compact progress in a card, avatar or list row
  • Goals and quotas where the share of a whole matters
  • Anywhere a bar is too wide

When not to use

  • Indeterminate loading, use a spinner or a progress bar
  • Several values compared side by side, use a bar chart

Agent notes

  • Server component. The ring starts at the top and fills clockwise
  • The radius is (size - stroke) / 2 so the stroke never clips the box
  • Colours come from foreground tones; wrap it in text-brand only if you add a mark yourself
  • The offset eases over 200ms and the transition is off under reduced motion

The maths

  • Radius r = (size - stroke) / 2, so a 24px ring with a 2px stroke has r = 11.
  • Circumference 2πr = 69.12 is the dasharray.
  • Dashoffset 2πr × (1 - p) = 69.12 × 0.3 = 20.73 at 70 percent.