# Progress (Moonarc)

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.

- Import: `import Progress from '@moonarc/core/Progress'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/progress.json`
- Tier A · category loading · trigger scroll
- Readout: `<Progress value={62}>`
- Browser support: newly (Chrome 91 · Firefox 128 · Safari 17); elsewhere: the bar fills; the number appears without counting
- Measured cost: 0 B JS of its own · uses Reveal + runtime (with dependencies 3.0 kB raw; CSS 9.4 kB raw)
- Page: https://moonarc.dev/components/progress/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` | `number` | none | Value, 0–max. |
| `max` | `number` | `100` | Maximum. |
| `label` | `string` | none | Label before the number; also the accessible name. |
| `showValue` | `boolean` | `true` | Show the counting number. |
| `suffix` | `string` | `'%'` | Suffix after the number. |
| `duration` | `number` | `1400` | Duration in ms, shared by the fill and the count. |
| `height` | `string` | `'0.5rem'` | Bar thickness. |
| `color` | `string` | `'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. |
| `size` | `string` | `'6rem'` | Ring diameter. |

## Usage

```astro
<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" />
```

## Reduced motion

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

## With ClientRouter

Inherits Reveal: rebinds after every navigation.

## Craft

- A progressbar needs a name: label is the visible one; without it, pass aria-label or aria-labelledby, or aria-hidden="true" when the same number already sits beside the bar as text (a table cell, a stat).
- Fill and count share one duration and both ease out, so the number lands the instant the bar stops.
- The fill is a full-length rounded bar that translate slides in from the left, clipped by the track: one compositor property, and its end stays a half circle at any value. A horizontal scale would squash that end towards flat as the value nears zero.
- role="progressbar" with aria-valuenow; the bar is static information to assistive tech, the motion is for the eye.
- The ring is a circle with pathLength="1", so the fill is one dash offset from 1 to 1 − value whatever the diameter; it starts at twelve o'clock (rotate: −90°) and the number sits in the same grid cell.

## Replaces

- Progress (shadcn)
- animated skill bars

## Source

```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>

```
