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.

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 { strong } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

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 `32`. */
  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;
  /** 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 ?? 32;
  const strokeWidth = props.strokeWidth ?? 10;
  const autoPlayIntervalMs = props.autoPlayIntervalMs ?? 2000;
  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);

  const trackCircle: DomphyElement<"circle"> = {
    circle: null,
    cx: String(VIEWBOX_CENTER),
    cy: String(VIEWBOX_CENTER),
    r: String(radius),
    fill: "none",
    strokeWidth: String(strokeWidth),
    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),
    } 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 cubic-bezier(0.4, 0, 0.2, 1)",
    } as StyleObject,
  } as DomphyElement<"circle">;

  const percentReadout: DomphyElement<"strong"> = {
    strong: (listener: Listener) => `${Math.round(percent(listener))}%`,
    $: [strong({ color: primaryColor })],
    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),
    _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