Today's content plan
- Spring email
- Weekly report
- Reddit replies
- YouTube script
Small progress ring with a count or glyph in the center.
DataAtomNo dependencies Beautiful UI, MIT
foundation.css holds the tokens every component reads.
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
--- Paste into src/components/viewership/progress-ring/
---
import ProgressRing from "../components/viewership/progress-ring/ProgressRing.astro";
---
<ProgressRing /> | 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. |
---
/**
* 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> /**
* 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);
} Ported from Beautiful UI, MIT. Notices.