Skip to content

Components / Loading

Progress

A bar that fills to its value when it scrolls into view, with the number counting up beside it, or a ring with the number in the middle. No script of its own: Reveal is the trigger and CountUp the number; the fill is one translate transition, the ring one dash offset. The server HTML already shows the final state.

Live demo

Primitives at zero bytes67 of 102
Baseline widely57%
57%

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

Uses Reveal and the shared runtime (1.9 kB raw, once per site). With those included: 3.0 kB raw.

CSS 9.4 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 91 · Firefox 128 · Safari 17; needs property, counter-style. Elsewhere: the bar fills; the number appears without counting.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Progress from '@moonarc/core/Progress';
---
<Progress value={62} label="Profile complete" />
<Progress value={3.2} max={5} suffix=" GB" label="Storage used" />
<Progress value={78} shape="ring" size="7rem" label="Goal reached" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime, the base tokens and Count Up.

terminal
npx shadcn@latest add https://moonarc.dev/r/progress.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy Reveal.astro, runtime.ts and CountUp.astro, and add the JS gate to your head.

Progress.astro
---
/**
 * Progress — a bar that fills to its value when it scrolls into view, with
 * the number counting alongside; or a ring, with the number in the middle.
 * No script of its own: Reveal is the trigger, CountUp the number; the bar
 * is one translate transition and the ring one dash offset, both on the
 * active preset's curve. The server HTML already shows the final state.
 */
import type { HTMLAttributes } from 'astro/types';
import Reveal from './Reveal.astro';
import CountUp from './CountUp.astro';

interface Props extends HTMLAttributes<'div'> {
  /** Value, 0–max. */
  value: number;
  /** Maximum. */
  max?: number;
  /** Visible label before the number. */
  label?: string;
  /** Show the number. */
  showValue?: boolean;
  /** Suffix after the number. */
  suffix?: string;
  /** Duration in ms. */
  duration?: number;
  /** Bar thickness, any CSS length. */
  height?: string;
  /** Fill colour. */
  color?: string;
  /** A bar, or a ring with the number in the middle. */
  shape?: 'bar' | 'ring';
  /** Ring diameter, any CSS length. */
  size?: string;
}

const { value, max = 100, label, showValue = true, suffix = '%', duration = 1400, height = '0.5rem', color = 'currentColor', shape = 'bar', size = '6rem', class: className, style, ...rest } = Astro.props;
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const ring = shape === 'ring';
// the caller's style joins the variables: passed on through {...rest} it replaced them, and the bar stayed empty
const vars = [`--ma-prog:${pct / 100};--ma-prog-dur:${duration}ms;--ma-prog-h:${height};--ma-prog-color:${color}${ring ? `;--ma-prog-size:${size}` : ''}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Reveal
  as="div"
  from="none"
  duration={300}
  class={['ma-progress', ring && 'ma-progress--ring', className].filter(Boolean).join(' ')}
  role="progressbar"
  aria-valuenow={value}
  aria-valuemin={0}
  aria-valuemax={max}
  aria-label={label}
  style={vars}
  {...rest}
>
  {ring ? (
    <>
      <span class="ma-progress__ring">
        <svg viewBox="0 0 100 100" aria-hidden="true">
          <circle class="ma-progress__ring-track" cx="50" cy="50" r="44" />
          <circle class="ma-progress__ring-fill" cx="50" cy="50" r="44" pathLength="1" />
        </svg>
        {showValue && <span class="ma-progress__center"><CountUp to={value} suffix={suffix} duration={duration} decimals={Number.isInteger(value) ? 0 : 1} class="ma-progress__value" /></span>}
      </span>
      {label && <span class="ma-progress__label">{label}</span>}
    </>
  ) : (
    <>
      {(label || showValue) && (
        <div class="ma-progress__head">
          {label && <span class="ma-progress__label">{label}</span>}
          {showValue && <CountUp to={value} suffix={suffix} duration={duration} decimals={Number.isInteger(value) ? 0 : 1} class="ma-progress__value" />}
        </div>
      )}
      <span class="ma-progress__track"><span class="ma-progress__fill"></span></span>
    </>
  )}
</Reveal>

<style is:global>
  @layer components {
    :where(.ma-progress__head) {
      display: flex;
      justify-content: space-between;
      gap: 1rem;
      margin-bottom: 0.5rem;
      font-size: 0.875rem;
    }
    :where(.ma-progress__value) {
      font-variant-numeric: tabular-nums;
    }
    :where(.ma-progress__track) {
      display: block;
      height: var(--ma-prog-h, 0.5rem);
      border-radius: 999px;
      background: var(--ma-edge);
      overflow: hidden;
    }
    /* the fill is a full-length rounded bar slid in from the left, clipped by the track: its end stays a half circle at
       any value, where a horizontal scale squashed it flat towards zero */
    :where(.ma-progress__fill) {
      display: block;
      height: 100%;
      border-radius: inherit;
      background: var(--ma-prog-color, currentColor);
      translate: calc((var(--ma-prog, 0) - 1) * 100%) 0;
      transition: translate var(--ma-prog-dur, 1400ms) var(--ma-ease-out);
    }
    :where([data-ma-js] .ma-progress:not(.ma-in) .ma-progress__fill) {
      translate: -100% 0;
      transition: none;
    }
    /* ring: one dash offset on a normalised circle, the number in the middle */
    :where(.ma-progress--ring) {
      display: inline-grid;
      justify-items: center;
      gap: 0.5rem;
    }
    :where(.ma-progress__ring) {
      position: relative;
      display: grid;
      place-items: center;
      width: var(--ma-prog-size, 6rem);
      height: var(--ma-prog-size, 6rem);
    }
    :where(.ma-progress__ring > svg) {
      grid-area: 1 / 1;
      width: 100%;
      height: 100%;
      rotate: -90deg;
    }
    :where(.ma-progress__ring-track),
    :where(.ma-progress__ring-fill) {
      fill: none;
      stroke-width: 8;
    }
    :where(.ma-progress__ring-track) {
      stroke: var(--ma-edge);
    }
    :where(.ma-progress__ring-fill) {
      stroke: var(--ma-prog-color, currentColor);
      stroke-linecap: round;
      stroke-dasharray: 1;
      /* × 1px: Firefox rejects a unitless calc() here; with pathLength="1" a px is a path unit */
      stroke-dashoffset: calc((1 - var(--ma-prog, 0)) * 1px);
      transition: stroke-dashoffset var(--ma-prog-dur, 1400ms) var(--ma-ease-out);
    }
    :where([data-ma-js] .ma-progress:not(.ma-in) .ma-progress__ring-fill) {
      stroke-dashoffset: 1;
      transition: none;
    }
    :where(.ma-progress__center) {
      grid-area: 1 / 1;
      font-size: calc(var(--ma-prog-size, 6rem) / 4.5);
      font-weight: 600;
      letter-spacing: -0.02em;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-progress__fill),
      :where(.ma-progress__ring-fill) {
        transition: none;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
valuenumbernoneValue, 0–max.
maxnumber100Maximum.
labelstringnoneLabel before the number; also the accessible name.
showValuebooleantrueShow the counting number.
suffixstring'%'Suffix after the number.
durationnumber1400Duration in ms, shared by the fill and the count.
heightstring'0.5rem'Bar thickness.
colorstring'currentColor'Fill colour.
shape'bar' | 'ring''bar'A bar, or an SVG ring that fills clockwise from the top with the number in the middle.
sizestring'6rem'Ring diameter.

Reduced motion

The bar is full at its value and the number is shown; nothing animates.

With ClientRouter

Inherits Reveal: rebinds after every navigation.

Why it is built this way

Replaces: Progress (shadcn) · animated skill bars. See the migration table.

Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

Skeleton

Loading

Placeholder lines, optionally with an avatar, shimmering while content loads.

every browseralways

Spinner

Loading

An indeterminate arc whose length breathes while it rotates, so it looks like it is working rather than spinning a fixed wedge.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown