Skip to content

Progress

Determinate and indeterminate progress as a bar or a ring, with four tones.

Feedbackstarterprogressloadingusagequotaring

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Progress, ProgressRing } from '@/components/ui/progress'
import { DemoColumn, DemoRow, DemoStage } from './_kit'

export default function ProgressDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Progress value={68} label="Seats used" showValue />
        <Progress value={92} tone="warning" label="Storage" showValue />
        <Progress value={100} tone="success" label="Migration" showValue />
        <Progress value={12} tone="danger" label="Test coverage" showValue />
        <Progress label="Indeterminate — duration unknown" />
        <Progress value={45} size="sm" />
      </DemoColumn>

      <DemoRow label="Ring" description="For dashboard tiles where a bar would waste width.">
        <ProgressRing value={68} label="Seats used" />
        <ProgressRing value={92} tone="warning" label="Storage" />
        <ProgressRing value={100} tone="success" label="Migration" />
        <ProgressRing value={12} tone="danger" label="Coverage" size={72} />
      </DemoRow>
    </DemoStage>
  )
}

components/demos/progress.tsx

import { Progress, ProgressRing } from '@/components/ui/progress'
import { DemoColumn, DemoRow, DemoStage } from './_kit'

export default function ProgressDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Progress value={68} label="Seats used" showValue />
        <Progress value={92} tone="warning" label="Storage" showValue />
        <Progress value={100} tone="success" label="Migration" showValue />
        <Progress value={12} tone="danger" label="Test coverage" showValue />
        <Progress label="Indeterminate — duration unknown" />
        <Progress value={45} size="sm" />
      </DemoColumn>

      <DemoRow label="Ring" description="For dashboard tiles where a bar would waste width.">
        <ProgressRing value={68} label="Seats used" />
        <ProgressRing value={92} tone="warning" label="Storage" />
        <ProgressRing value={100} tone="success" label="Migration" />
        <ProgressRing value={12} tone="danger" label="Coverage" size={72} />
      </DemoRow>
    </DemoStage>
  )
}

components/ui/progress.tsx

import { cn } from '@/lib/cn'

/**
 * Progress
 *
 * Determinate by default. When `value` is omitted the bar renders as
 * indeterminate and drops `aria-valuenow`, which is what tells assistive tech
 * "in progress, duration unknown" rather than "0%".
 */
export interface ProgressProps {
  /** 0–100. Omit for an indeterminate bar. */
  value?: number
  label?: string
  /** Renders the numeric value beside the label. */
  showValue?: boolean
  /** Accessible name when no visible label is wanted. */
  srLabel?: string
  tone?: 'accent' | 'success' | 'warning' | 'danger'
  size?: 'sm' | 'md'
  className?: string
}

const tones = {
  accent: 'bg-accent',
  success: 'bg-success',
  warning: 'bg-warning',
  danger: 'bg-danger',
} as const

export function Progress({
  value,
  label,
  showValue = false,
  srLabel,
  tone = 'accent',
  size = 'md',
  className,
}: ProgressProps) {
  const clamped = value === undefined ? undefined : Math.max(0, Math.min(100, Math.round(value)))

  return (
    <div className={cn('w-full', className)}>
      {(label || showValue) && (
        <div className="mb-1.5 flex items-baseline justify-between gap-3">
          {label ? <span className="text-xs font-medium text-ink">{label}</span> : <span />}
          {showValue && clamped !== undefined ? (
            <span className="font-mono text-xs text-ink-muted tabular-nums">{clamped}%</span>
          ) : null}
        </div>
      )}
      <div
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={clamped}
        aria-label={label ? undefined : (srLabel ?? 'Progress')}
        aria-valuetext={clamped === undefined ? 'In progress' : `${clamped}%`}
        className={cn(
          'w-full overflow-hidden rounded-full bg-surface-sunken',
          size === 'sm' ? 'h-1' : 'h-2',
        )}
      >
        {clamped === undefined ? (
          <div className={cn('h-full w-2/5 animate-pulse-token rounded-full', tones[tone])} />
        ) : (
          <div
            className={cn(
              'h-full rounded-full transition-[width] duration-300 ease-standard',
              tones[tone],
            )}
            style={{ width: `${clamped}%` }}
          />
        )}
      </div>
    </div>
  )
}

export interface ProgressRingProps {
  value: number
  size?: number
  label?: string
  tone?: keyof typeof tones
  className?: string
}

/** Circular variant, for dashboard tiles where a bar would waste width. */
export function ProgressRing({
  value,
  size = 56,
  label,
  tone = 'accent',
  className,
}: ProgressRingProps) {
  const clamped = Math.max(0, Math.min(100, Math.round(value)))
  const stroke = 5
  const radius = (size - stroke) / 2
  const circumference = 2 * Math.PI * radius
  const offset = circumference - (clamped / 100) * circumference
  const strokeColor = {
    accent: 'var(--color-accent)',
    success: 'var(--color-success)',
    warning: 'var(--color-warning)',
    danger: 'var(--color-danger)',
  }[tone]

  return (
    <div
      className={cn('relative inline-flex items-center justify-center', className)}
      role="progressbar"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={clamped}
      aria-label={label ?? 'Progress'}
    >
      <svg width={size} height={size} className="-rotate-90" aria-hidden="true">
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke="var(--color-surface-sunken)"
          strokeWidth={stroke}
        />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke={strokeColor}
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={circumference}
          strokeDashoffset={offset}
        />
      </svg>
      <span className="absolute font-mono text-xs font-medium tabular-nums">{clamped}%</span>
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Determinate when you know the proportion, indeterminate when you do not. Omitting `value` drops `aria-valuenow`, which is what tells assistive tech "in progress, duration unknown" rather than "0 percent".

  • Values are clamped and rounded, so an out-of-range number cannot overflow the track.
  • Use the ring in dashboard tiles where a full-width bar would waste horizontal space.
  • Tone should reflect the meaning of the number: 92% storage is a warning, 92% test coverage is not.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Determinate bar
  • Indeterminate bar
  • Ring
  • Four tones
  • Two heights

Accessibility

Role
`role="progressbar"` with min, max and, when known, `aria-valuenow`.
Value text
`aria-valuetext` reads "In progress" when indeterminate rather than announcing a misleading zero.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.