Count Up
TextCounts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
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, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
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
Chrome 91 · Firefox 128 · Safari 17; needs property, counter-style. Elsewhere: the bar fills; the number appears without counting.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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" />Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime, the base tokens and Count Up.
npx shadcn@latest add https://moonarc.dev/r/progress.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
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 — 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>| 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. |
The bar is full at its value and the number is shown; nothing animates.
ClientRouterInherits Reveal: rebinds after every navigation.
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.
Placeholder lines, optionally with an avatar, shimmering while content loads.
An indeterminate arc whose length breathes while it rotates, so it looks like it is working rather than spinning a fixed wedge.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown