# Goal Progress Rings — React Dashboard Widget

> Concentric rings sweep toward each target beside a list of matching bars; hovering a goal isolates its ring and swaps the centre readout.

- Category: [Dashboard Widgets](https://pixelpapa.com/components/dashboard.md)
- Tags: dashboard, goals, progress, rings, okr
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/dashboard/dash-goal-progress

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/dash-goal-progress.json"
```

The file is written to `components/pixelpapa/dash-goal-progress.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Check } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/utils";

export type Goal = { label: string; value: number; target: number; color?: string; format?: (v: number) => string };

type GoalProgressProps = { goals: Goal[]; title?: string; className?: string };

const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"];
const STROKE = 13;
const SPACING = 18;

export function GoalProgress({ goals, title = "Quarter goals", className }: GoalProgressProps) {
  const reduce = useReducedMotion();
  const [active, setActive] = useState<number | null>(null);
  const items = goals.map((g, i) => ({
    ...g,
    color: g.color ?? PALETTE[i % PALETTE.length],
    pct: Math.min(1, g.value / g.target),
    fmt: g.format ?? ((v: number) => v.toLocaleString("en")),
  }));
  const overall = items.reduce((s, g) => s + g.pct, 0) / items.length;
  const shown = active === null ? overall : items[active].pct;

  return (
    <div className={cn("flex w-full flex-col items-center gap-6 sm:flex-row sm:gap-8", className)}>
      <div className="relative size-48 shrink-0">
        <svg viewBox="0 0 200 200" className="size-full -rotate-90" role="img" aria-label={`${title}: ${Math.round(overall * 100)}% complete overall`}>
          {items.map((g, i) => {
            const r = 90 - i * SPACING;
            return (
              <g key={g.label} className="transition-opacity duration-200" opacity={active === null || active === i ? 1 : 0.25}>
                <circle cx={100} cy={100} r={r} fill="none" strokeWidth={STROKE} stroke={g.color} strokeOpacity={0.14} />
                <motion.circle
                  cx={100}
                  cy={100}
                  r={r}
                  fill="none"
                  strokeWidth={STROKE}
                  strokeLinecap="round"
                  stroke={g.color}
                  initial={reduce ? false : { pathLength: 0 }}
                  animate={{ pathLength: Math.max(g.pct, 0.001) }}
                  transition={{ duration: 1.3, delay: 0.1 + i * 0.14, ease: [0.22, 1, 0.36, 1] }}
                />
              </g>
            );
          })}
        </svg>
        <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.p
              key={active ?? "all"}
              initial={{ opacity: 0, scale: 0.8 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.8 }}
              transition={{ duration: 0.18 }}
              className="text-xl font-semibold tracking-tight text-foreground tabular-nums"
            >
              {Math.round(shown * 100)}%
            </motion.p>
          </AnimatePresence>
        </div>
      </div>

      <ul className="w-full min-w-0 flex-1 space-y-1">
        {items.map((g, i) => (
          <li key={g.label}>
            <button
              type="button"
              onPointerEnter={() => setActive(i)}
              onPointerLeave={() => setActive(null)}
              onFocus={() => setActive(i)}
              onBlur={() => setActive(null)}
              className={cn(
                "block w-full rounded-xl px-3 py-2.5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                active === i && "bg-muted",
              )}
            >
              <span className="flex items-center gap-2 text-sm">
                <span className="size-2.5 rounded-full" style={{ background: g.color }} />
                <span className="font-medium text-foreground">{g.label}</span>
                {g.pct >= 1 && (
                  <span className="flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white">
                    <Check className="size-2.5" strokeWidth={3.5} />
                  </span>
                )}
                <span className="ml-auto text-xs text-muted-foreground tabular-nums">
                  <span className="font-medium text-foreground">{g.fmt(g.value)}</span> / {g.fmt(g.target)}
                </span>
              </span>
              <span className="mt-2 block h-1.5 overflow-hidden rounded-full bg-muted-foreground/15">
                <motion.span
                  className="block h-full rounded-full"
                  style={{ background: g.color }}
                  initial={reduce ? false : { width: 0 }}
                  animate={{ width: `${g.pct * 100}%` }}
                  transition={{ duration: 1.1, delay: 0.2 + i * 0.14, ease: [0.22, 1, 0.36, 1] }}
                />
              </span>
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

const usdK = (v: number) => `$${(v / 1000).toFixed(0)}k`;
const goals: Goal[] = [
  { label: "New ARR", value: 412000, target: 500000, format: usdK },
  { label: "Qualified demos", value: 186, target: 240 },
  { label: "Activation rate", value: 61, target: 60, format: (v) => `${v}%` },
  { label: "Docs shipped", value: 23, target: 48 },
];

export default function DashGoalProgressDemo() {
  return (
    <div className="w-full max-w-xl rounded-2xl border border-border bg-card p-6 shadow-sm">
      <div className="mb-5 flex items-center justify-between">
        <div>
          <p className="font-medium text-card-foreground">Q4 goals</p>
          <p className="text-sm text-muted-foreground">Week 7 of 13</p>
        </div>
        <span className="rounded-full bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400">On track</span>
      </div>
      <GoalProgress goals={goals} title="Q4 goals" />
    </div>
  );
}
```

## AI prompt

```text
Add the "Goal Progress Rings" component from PixelPapa UI to this project.

What it is: Concentric rings sweep toward each target beside a list of matching bars; hovering a goal isolates its ring and swaps the centre readout.
Category: Dashboard Widgets. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/dash-goal-progress.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/dash-goal-progress.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/dash-goal-progress" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More dashboard widgets

- [KPI Tiles](https://pixelpapa.com/components/dashboard/dash-kpi-tiles.md) (Pro): Metric tiles that count up to their value, show a coloured delta pill and draw an edge-to-edge sparkline along the bottom.
- [Live Activity Feed](https://pixelpapa.com/components/dashboard/dash-activity-feed.md): A feed where new events drop in at the top on a timer with a brief highlight while older rows slide down and the last one fades out; pauses on hover.
- [Live Visitors](https://pixelpapa.com/components/dashboard/dash-live-visitors.md) (Pro): A real-time visitors widget: digits flip as the count ticks, cities pulse on a dotted world map and a bar stream scrolls the last minute.
- [Revenue Card With Period Switcher](https://pixelpapa.com/components/dashboard/dash-revenue-card.md): A revenue card whose period tabs slide a pill, roll every digit of the total like an odometer and spring the bar chart to the new shape.
- [Team Table Mini](https://pixelpapa.com/components/dashboard/dash-team-table-mini.md) (Pro): A compact members table with presence dots, an inline role picker and a status filter whose pill slides while rows reflow into place.
- [Usage Meter](https://pixelpapa.com/components/dashboard/dash-usage-meter.md) (Pro): Two plan meters: a storage bar whose category segments fill in sequence, and a seat meter of spring-loaded pips you can add to or remove from.
