# Progress Ring

> Small progress ring with a count or glyph in the center.

- **Slug:** `progress-ring`
- **Kind:** atom · **Category:** Data
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/atoms/ProgressRing.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/progress-ring
- **All files in one bundle:** https://viewership.ai/ui/components/progress-ring.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/progress-ring.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy these files into `src/components/viewership/progress-ring/`:

   - `ProgressRing.astro` (raw: https://viewership.ai/ui/raw/progress-ring/ProgressRing.astro.txt)
   - `progress-ring.ts` (raw: https://viewership.ai/ui/raw/progress-ring/progress-ring.ts.txt)

npm packages: none.

## Usage

```astro
---
import ProgressRing from "../components/viewership/progress-ring/ProgressRing.astro";
---

<ProgressRing />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import ProgressRing from "./ProgressRing.astro";
import Button from "../button/Button.astro";

const plan = [
  { lot: "Spring email", done: 5, total: 5, tone: "green", note: "Approved" },
  { lot: "Weekly report", done: 2, total: 5, tone: "orange", note: "In review", live: true },
  { lot: "Reddit replies", done: 1, total: 4, tone: "accent", note: "Next up" },
  { lot: "YouTube script", done: 1, total: 6, tone: "red", note: "Behind plan" },
] as const;
---

<div class="card" data-ring-demo>
  <div class="head">
    <span class="title">Today's content plan</span>
    <Button variant="secondary" size="xs" data-log>Log draft</Button>
  </div>
  <ul class="list">
    {plan.map((row) => (
      <li class="row">
        <ProgressRing
          progress={row.done / row.total}
          tone={row.tone}
          label={`${row.lot} drafts`}
          valueText={`${row.done} of ${row.total} drafts`}
          data-live={"live" in row ? "" : undefined}
          data-done={row.done}
          data-total={row.total}
        >
          {row.done}
        </ProgressRing>
        <span class="lot">{row.lot}</span>
        <span class="meta"><span data-count>{row.done}</span> of {row.total} · {row.note}</span>
      </li>
    ))}
  </ul>
</div>

<script>
  import { setProgressRing } from "./progress-ring";

  const boot = () =>
    document.querySelectorAll<HTMLElement>("[data-ring-demo]:not([data-vs-ready])").forEach((demo) => {
      demo.dataset.vsReady = "";
      const ring = demo.querySelector<HTMLElement>(".vs-progress-ring[data-live]");
      const count = ring?.closest(".row")?.querySelector("[data-count]");
      const button = demo.querySelector<HTMLButtonElement>("[data-log]");
      if (!ring || !button) return;
      const total = Number(ring.dataset.total);
      button.addEventListener("click", () => {
        const done = (Number(ring.dataset.done) + 1) % (total + 1);
        ring.dataset.done = String(done);
        setProgressRing(ring, done / total, `${done} of ${total} drafts`);
        const label = ring.querySelector(".vs-progress-ring__content");
        if (label) label.textContent = String(done);
        if (count) count.textContent = String(done);
      });
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .card {
    max-width: 380px;
    margin: 0 auto;
    padding: 14px 16px 6px;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }
  .head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 4px;
  }
  .title {
    font-size: 13px;
    font-weight: 600;
    color: var(--vs-ink);
  }
  .list {
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .row {
    display: flex;
    align-items: center;
    gap: 10px;
    min-height: 48px;
  }
  .row + .row {
    border-top: 1px solid var(--vs-line);
  }
  .lot {
    flex: 1;
    min-width: 0;
    font-size: 14px;
    color: var(--vs-ink);
  }
  .meta {
    font-size: 13px;
    color: var(--vs-ink-3);
    font-variant-numeric: tabular-nums;
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `progress` | `number` |  | 0 to 1. |
| `tone` | `"orange" \| "green" \| "red" \| "accent"` | `"orange"` | Arc color. |
| `size` | `number` | `28` | Diameter in px. |
| `stroke` | `number` | `2` | Stroke width in px. |
| `label` | `string` |  | Accessible name (or pass aria-labelledby). |
| `valueText` | `string` |  | Spoken value, e.g. "3 of 5 drafts". Center slot is visual only. |
| `setProgressRing(el, p, text?)` | `(el: HTMLElement, progress: number, valueText?: string) => void` |  | Animate to a new value and sync ARIA. |

## Source

### `src/components/viewership/progress-ring/ProgressRing.astro`

```astro
---
/**
 * ProgressRing: a small progress ring with content in the center; a task
 * badge.
 *
 * Ported from Beautiful UI `components/atoms/ProgressRing.tsx`
 * (MIT, © 2026 Shane Levine). A `progressbar` (0–100). The center slot is
 * visual, so describe it with `valueText` (e.g. "3 of 5 drafts").
 * Update from script with `setProgressRing(el, 0.6)` from `./progress-ring`.
 *
 * @example
 * <ProgressRing progress={0.6} tone="green" label="Report 214" valueText="3 of 5 steps">3</ProgressRing>
 */
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"span"> & {
  /** 0..1 */
  progress: number;
  tone?: "orange" | "green" | "red" | "accent";
  /** Diameter in px. */
  size?: number;
  /** Stroke width in px. */
  stroke?: number;
  /** Accessible name (or pass aria-labelledby). */
  label?: string;
  /** Human-readable value for assistive tech, e.g. "3 of 5 drafts". */
  valueText?: string;
};

const { progress, tone = "orange", size = 28, stroke = 2, label, valueText, class: className, style, ...rest } = Astro.props;
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 0));
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const rootStyle = [`width: ${size}px; height: ${size}px`, typeof style === "string" ? style : ""].filter(Boolean).join("; ");
---

<span
  class:list={["vs-progress-ring", `vs-progress-ring--${tone}`, className]}
  role="progressbar"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuenow={Math.round(p * 100)}
  aria-valuetext={valueText}
  aria-label={label}
  style={rootStyle}
  {...rest}
>
  <svg class="vs-progress-ring__svg" width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
    <circle class="vs-progress-ring__track" cx={size / 2} cy={size / 2} r={r} fill="none" stroke-width={stroke} />
    <circle
      class="vs-progress-ring__bar"
      cx={size / 2}
      cy={size / 2}
      r={r}
      fill="none"
      stroke-width={stroke}
      stroke-linecap="round"
      stroke-dasharray={c}
      stroke-dashoffset={c * (1 - p)}
    />
  </svg>
  <span class="vs-progress-ring__content" aria-hidden="true"><slot /></span>
</span>

<style>
  .vs-progress-ring {
    position: relative;
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
  }
  .vs-progress-ring__svg {
    position: absolute;
    inset: 0;
    transform: rotate(-90deg);
  }
  .vs-progress-ring__track {
    stroke: var(--vs-line);
  }
  .vs-progress-ring__bar {
    stroke: var(--vs-ring-color);
    transition: stroke-dashoffset 400ms var(--vs-ease-out);
  }
  .vs-progress-ring__content {
    position: relative;
    font-size: 12px;
    font-weight: 600;
    line-height: 1;
    font-variant-numeric: tabular-nums;
  }

  .vs-progress-ring--orange {
    --vs-ring-color: var(--vs-orange);
  }
  .vs-progress-ring--green {
    --vs-ring-color: var(--vs-green);
  }
  .vs-progress-ring--red {
    --vs-ring-color: var(--vs-red);
  }
  .vs-progress-ring--accent {
    --vs-ring-color: var(--vs-accent);
  }

  @media (prefers-reduced-motion: reduce) {
    .vs-progress-ring__bar {
      transition: none;
    }
  }
  @media (forced-colors: active) {
    .vs-progress-ring__track {
      stroke: GrayText;
    }
    .vs-progress-ring__bar {
      stroke: Highlight;
    }
  }
</style>
```

### `src/components/viewership/progress-ring/progress-ring.ts`

```ts
/**
 * progress-ring: update a <ProgressRing> from script.
 *
 * Ported from Beautiful UI `components/atoms/ProgressRing.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 *   setProgressRing(ringEl, 0.6, "3 of 5 drafts");
 *
 * Animates the arc and syncs aria-valuenow and aria-valuetext. Update the
 * center content yourself.
 */
export function setProgressRing(root: HTMLElement, progress: number, valueText?: string): void {
  const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 0));
  const bar = root.querySelector<SVGCircleElement>(".vs-progress-ring__bar");
  if (bar) {
    const c = 2 * Math.PI * bar.r.baseVal.value;
    bar.style.strokeDasharray = String(c);
    bar.style.strokeDashoffset = String(c * (1 - p));
  }
  root.setAttribute("aria-valuenow", String(Math.round(p * 100)));
  if (valueText !== undefined) root.setAttribute("aria-valuetext", valueText);
}
```
