animatedCircularProgressBar
A Community block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call animatedCircularProgressBar() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
value | ValueOrState<number> | Current progress value. Accepts a value or reactive state. When omitted, the component drives its own demo state, cycling upward in 10% steps. |
min | number | Lower bound of value's range. Defaults to 0. |
max | number | Upper bound of value's range. Defaults to 100. |
primaryColor | ThemeColor | Theme color for the progress arc. Defaults to "primary". |
secondaryColor | ThemeColor | Theme color for the background track. Defaults to "neutral". |
size | number | Ring diameter, in themeSpacing units. Defaults to 40 (upstream size-40 = 10rem). |
strokeWidth | number | SVG stroke width, in viewBox user units (0–100 viewBox, so this is roughly a percentage of the ring's own diameter). Defaults to 10. |
autoPlayIntervalMs | number | Interval, in ms, between auto-demo steps when value is omitted. Defaults to 2000. |
label | string | Accessible name for the role="progressbar" element. Defaults to "Progress". |
style | StyleObject | Passthrough style merged onto the outer container. |
Implementation notes
Full visual/behavior implemented with raw SVG <circle> elements (100x100 viewBox, radius = 50 - strokeWidth/2, matching the research note's ~45/~10 ratio at the strokeWidth=10 default) rather than reusing @domphy/ui's ringProgress() patch, since ringProgress draws its arc via a conic-gradient + circular mask (a wedge fill) whereas the spec explicitly calls for a stroke-dasharray/stroke-dashoffset dash-pattern arc with a rounded starting cap — a materially different rendering technique the existing patch doesn't offer. stroke-dashoffset is a reactive CSS custom property-free function with a 1s cubic-bezier CSS transition, so the arc visually sweeps to the new percentage on every value change; the SVG is rotated -90deg so the sweep starts at 12 o'clock (mirroring ringProgress()'s own 'from -90deg' convention). The centered percentage readout briefly replays a WAAPI opacity fade on every value change (the spec's 'light fade-in on update'). When no value prop is passed the component self-drives a demo state via setInterval, cycling +10% every 2s and wrapping back to min, matching the spec's described docs-page demo behavior; passing a value/State disables the auto-demo. Doctor-clean; 2 vitest assertions cover initial render (aria-value* + circle/readout counts) and reactive updates when the external state changes.
Status: ported · Reference: Magic UI original
// magicui "Animated Circular Progress Bar" — clean-room reimplementation from
// the public behavior/visual spec only (no upstream source viewed or
// copied). A thick circular ring gauge: a neutral background "track" circle
// drawn first, with a saturated accent "progress" arc drawn on top of it
// starting at 12 o'clock and sweeping clockwise, plus a centered percentage
// readout.
//
// Built from raw SVG `<circle>` elements (100x100 viewBox, radius/stroke
// ratio matching the reference's ~45/~10) rather than `ringProgress()`'s
// `conic-gradient` + circular `mask` technique — the spec explicitly calls
// for a dash-length/gap stroke pattern, and expressing the arc via
// `stroke-dasharray`/`stroke-dashoffset` (rather than a conic-gradient wedge)
// keeps the sweep's start-cap rounded (`stroke-linecap: round`) and lets the
// whole arc animate as one continuous CSS `transition` on `stroke-dashoffset`
// when the value changes.
import type { DomphyElement, Listener, StyleObject } from "@domphy/core";
import { toState, type ValueOrState } from "@domphy/core";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
import { strong } from "@domphy/ui";
export interface AnimatedCircularProgressBarProps {
/** Current progress value. Accepts a value or reactive state. When omitted, the
* component drives its own demo state, cycling upward in 10% steps. */
value?: ValueOrState<number>;
/** Lower bound of `value`'s range. Defaults to `0`. */
min?: number;
/** Upper bound of `value`'s range. Defaults to `100`. */
max?: number;
/** Theme color for the progress arc. Defaults to `"primary"`. */
primaryColor?: ThemeColor;
/** Theme color for the background track. Defaults to `"neutral"`. */
secondaryColor?: ThemeColor;
/** Ring diameter, in `themeSpacing` units. Defaults to `40` (upstream `size-40` = 10rem). */
size?: number;
/** SVG stroke width, in viewBox user units (0–100 viewBox, so this is roughly a
* percentage of the ring's own diameter). Defaults to `10`. */
strokeWidth?: number;
/** Interval, in ms, between auto-demo steps when `value` is omitted. Defaults to `2000`. */
autoPlayIntervalMs?: number;
/** Accessible name for the `role="progressbar"` element. Defaults to `"Progress"`. */
label?: string;
/** Passthrough style merged onto the outer container. */
style?: StyleObject;
}
const VIEWBOX_SIZE = 100;
const VIEWBOX_CENTER = VIEWBOX_SIZE / 2;
function clampPercent(value: number, min: number, max: number): number {
if (max <= min) return 0;
return Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
}
/**
* A circular ring gauge whose colored arc smoothly animates to a new
* percentage whenever its value changes, with the numeric percentage shown
* centered inside the ring. Call with no arguments for a working demo — the
* ring auto-cycles upward in 10% steps.
*/
function animatedCircularProgressBar(
props: AnimatedCircularProgressBarProps = {},
): DomphyElement<"div"> {
const min = props.min ?? 0;
const max = props.max ?? 100;
const primaryColor = props.primaryColor ?? "primary";
const secondaryColor = props.secondaryColor ?? "neutral";
const size = props.size ?? 40;
const strokeWidth = props.strokeWidth ?? 10;
const autoPlayIntervalMs = props.autoPlayIntervalMs ?? 2000;
const label = props.label ?? "Progress";
const hasExternalValue = props.value !== undefined;
const value = toState(props.value ?? min, "value");
const radius = VIEWBOX_CENTER - strokeWidth / 2;
const circumference = 2 * Math.PI * radius;
const percent = (listener: Listener) =>
clampPercent(value.get(listener), min, max);
// Upstream caps the background track at a 90%-of-circumference arc (never a
// fully closed ring) and hides it past 90%, leaving a fixed gap before the
// progress arc's start — reproduced here as an offset fraction rather than
// upstream's scaleY(-1)-flipped dash technique.
const trackFraction = (listener: Listener) =>
Math.max(0, (90 - percent(listener)) / 100);
const trackCircle: DomphyElement<"circle"> = {
circle: null,
cx: String(VIEWBOX_CENTER),
cy: String(VIEWBOX_CENTER),
r: String(radius),
fill: "none",
strokeWidth: String(strokeWidth),
strokeLinecap: "round",
ariaHidden: "true",
// Decorative background ring with no text of its own — exempt from the
// missing-color contract, matching meteors.ts's tail-gradient spans.
_doctorDisable: "missing-color",
style: {
stroke: (listener: Listener) =>
themeColor(listener, "shift-3", secondaryColor),
transform: "rotate(-18deg)",
strokeDasharray: `${circumference} ${circumference}`,
strokeDashoffset: (listener: Listener) =>
`${(-(circumference * (1 - trackFraction(listener)))).toFixed(2)}`,
transition: "stroke-dashoffset 1s ease",
} as StyleObject,
} as DomphyElement<"circle">;
const progressCircle: DomphyElement<"circle"> = {
circle: null,
cx: String(VIEWBOX_CENTER),
cy: String(VIEWBOX_CENTER),
r: String(radius),
fill: "none",
strokeWidth: String(strokeWidth),
strokeLinecap: "round",
ariaHidden: "true",
_doctorDisable: "missing-color",
style: {
stroke: (listener: Listener) =>
themeColor(listener, "shift-9", primaryColor),
strokeDasharray: `${circumference} ${circumference}`,
strokeDashoffset: (listener: Listener) =>
`${(circumference - (percent(listener) / 100) * circumference).toFixed(2)}`,
transition: "stroke-dashoffset 1s ease",
} as StyleObject,
} as DomphyElement<"circle">;
const percentReadout: DomphyElement<"strong"> = {
strong: (listener: Listener) => String(Math.round(percent(listener))),
$: [strong()],
dataSize: "increase-2",
style: { margin: 0 } as StyleObject,
// Briefly replays a fade whenever the underlying value changes, so the
// number reads as updating rather than silently jumping — the light
// "fade-in on update" the spec calls for.
_onMount: (node) => {
const element = node.domElement as HTMLElement | null;
if (!element || typeof element.animate !== "function") return;
const release = value.addListener(() => {
element.animate([{ opacity: 0.35 }, { opacity: 1 }], {
duration: 250,
easing: "ease-out",
});
});
node.addHook("Remove", () => release());
},
};
return {
div: [
{
svg: [trackCircle, progressCircle],
viewBox: `0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`,
ariaHidden: "true",
// Rotates the arc's start point from SVG's default 3 o'clock to 12
// o'clock, matching ringProgress()'s `from -90deg` conic-gradient start.
style: {
display: "block",
width: "100%",
height: "100%",
transform: "rotate(-90deg)",
} as StyleObject,
} as DomphyElement<"svg">,
{
div: [percentReadout],
style: {
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
} as StyleObject,
} as DomphyElement<"div">,
],
role: "progressbar",
ariaValuenow: (listener: Listener) =>
String(Math.round(value.get(listener))),
ariaValuemin: String(min),
ariaValuemax: String(max),
ariaLabel: label,
_onMount: (node) => {
if (hasExternalValue || typeof window === "undefined") return;
const timer = setInterval(() => {
const next = value.get() + (max - min) / 10;
value.set(next > max ? min : next);
}, autoPlayIntervalMs);
node.addHook("Remove", () => clearInterval(timer));
},
style: {
position: "relative",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: themeSpacing(size),
height: themeSpacing(size),
...(props.style ?? {}),
} as StyleObject,
};
}
export { animatedCircularProgressBar };