Skip to content
Domphy

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

PropTypeDescription
valueValueOrState<number>Current progress value. Accepts a value or reactive state. When omitted, the component drives its own demo state, cycling upward in 10% steps.
minnumberLower bound of value's range. Defaults to 0.
maxnumberUpper bound of value's range. Defaults to 100.
primaryColorThemeColorTheme color for the progress arc. Defaults to "primary".
secondaryColorThemeColorTheme color for the background track. Defaults to "neutral".
sizenumberRing diameter, in themeSpacing units. Defaults to 40 (upstream size-40 = 10rem).
strokeWidthnumberSVG stroke width, in viewBox user units (0–100 viewBox, so this is roughly a percentage of the ring's own diameter). Defaults to 10.
autoPlayIntervalMsnumberInterval, in ms, between auto-demo steps when value is omitted. Defaults to 2000.
labelstringAccessible name for the role="progressbar" element. Defaults to "Progress".
styleStyleObjectPassthrough 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 };

← Back to Magic UI catalog