Skip to content

Progress toward goals

Ratios rather than totals — a ring for the headline, bars for the breakdown.

Marketingstarterstatsprogressgoalscoverage

Live preview

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

Source

This exact file renders the preview above.

import { Container } from '@/components/ui/layout'
import { Progress, ProgressRing } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'

/**
 * Progress toward goals
 *
 * Statistics that are ratios rather than totals. A ring carries the headline
 * figure; bars carry the breakdown. Both are `role="progressbar"` with real
 * values, so the numbers are available to assistive tech, not painted on.
 */
const goals = [
  { label: 'Components documented', value: 100, tone: 'success' as const },
  { label: 'Sections with usage notes', value: 100, tone: 'success' as const },
  { label: 'Starters with a 404 path', value: 100, tone: 'success' as const },
  { label: 'Manual screen-reader passes', value: 62, tone: 'warning' as const },
]

export default function ProgressGoalsStats() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid items-center gap-10 lg:grid-cols-[auto_1fr] lg:gap-16">
          <div className="flex flex-col items-center gap-3 text-center">
            <ProgressRing value={91} size={128} label="Overall documentation coverage" />
            <div>
              <p className="text-sm font-semibold text-ink-strong">Documentation coverage</p>
              <Badge tone="success" dot className="mt-1.5">
                On track
              </Badge>
            </div>
          </div>

          <div>
            <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
              What is finished, and what is honestly not.
            </h2>
            <p className="mt-3 max-w-lg text-md text-ink-muted">
              Automated checks cover a lot. Manual screen-reader passes cover the rest, and that
              work is still in progress.
            </p>
            <div className="mt-8 flex flex-col gap-4">
              {goals.map((goal) => (
                <Progress
                  key={goal.label}
                  value={goal.value}
                  tone={goal.tone}
                  label={goal.label}
                  showValue
                />
              ))}
            </div>
          </div>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/stats/progress-goals.tsx

import { Container } from '@/components/ui/layout'
import { Progress, ProgressRing } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'

/**
 * Progress toward goals
 *
 * Statistics that are ratios rather than totals. A ring carries the headline
 * figure; bars carry the breakdown. Both are `role="progressbar"` with real
 * values, so the numbers are available to assistive tech, not painted on.
 */
const goals = [
  { label: 'Components documented', value: 100, tone: 'success' as const },
  { label: 'Sections with usage notes', value: 100, tone: 'success' as const },
  { label: 'Starters with a 404 path', value: 100, tone: 'success' as const },
  { label: 'Manual screen-reader passes', value: 62, tone: 'warning' as const },
]

export default function ProgressGoalsStats() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="grid items-center gap-10 lg:grid-cols-[auto_1fr] lg:gap-16">
          <div className="flex flex-col items-center gap-3 text-center">
            <ProgressRing value={91} size={128} label="Overall documentation coverage" />
            <div>
              <p className="text-sm font-semibold text-ink-strong">Documentation coverage</p>
              <Badge tone="success" dot className="mt-1.5">
                On track
              </Badge>
            </div>
          </div>

          <div>
            <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
              What is finished, and what is honestly not.
            </h2>
            <p className="mt-3 max-w-lg text-md text-ink-muted">
              Automated checks cover a lot. Manual screen-reader passes cover the rest, and that
              work is still in progress.
            </p>
            <div className="mt-8 flex flex-col gap-4">
              {goals.map((goal) => (
                <Progress
                  key={goal.label}
                  value={goal.value}
                  tone={goal.tone}
                  label={goal.label}
                  showValue
                />
              ))}
            </div>
          </div>
        </div>
      </Container>
    </section>
  )
}

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

Both the ring and the bars are real progressbars with values, so the numbers are available to assistive tech rather than painted on.

  • Include at least one incomplete figure. A section where everything is 100% reads as marketing.
  • Tone the bars by meaning, not by value — 62% coverage is not automatically a warning.

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.

  • Progress ring
  • Four bars
  • Honest incomplete state

Accessibility

Progressbar roles
Ring and bars both expose min, max and current value.
Value text
Percentages are announced as text, not inferred from bar width.

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.