Bar Grow
DataA bar chart whose bars grow from the baseline one after another when it scrolls into view; hovering a bar shows its value.
A ring, a three-quarter arc or a half circle that fills to a value as it scrolls into view, the number counting up in the middle with it and a label under it. The SVG is drawn on the server; the value's stroke is one dash that grows from the start of the track, and its colour can follow the value through bands you set, picked at build (red under 99, green above). The element is a meter with the value and the label, so a screen reader hears it once. Composes Reveal and CountUp; no script of its own.
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.2 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 gauge and its number drawn full at once.
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 Gauge from '@moonarc/core/Gauge';
---
<Gauge value={99.98} decimals={2} suffix="%" label="Uptime, 90 days" />
<Gauge value={42} max={200} suffix=" ms" label="p95 latency" shape="arc" size="7rem" />
<Gauge
value={71}
shape="half"
label="Monthly quota"
bands={[
{ upTo: 60, color: 'var(--chart-2)' },
{ upTo: 85, color: 'oklch(0.75 0.15 75)' },
{ upTo: 100, color: 'oklch(0.6 0.2 25)' },
]}
/>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/gauge.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.
---
/**
* Gauge — a ring or an arc that fills to a value as it scrolls into view, the value counting up in the middle with it.
* The SVG is drawn on the server: a track the length of the shape and, over it, the value's stroke as one dash that
* grows from the start (its offset transitions to zero). The colour can follow the value through bands, picked at
* build. Composes Reveal for the trigger and CountUp for the number; no script of its own. The element is a meter, so
* a screen reader hears the label and the value once.
*/
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import Reveal from './Reveal.astro';
import CountUp from './CountUp.astro';
interface Band {
/** The band holds every value up to this one (inclusive). */
upTo: number;
/** Any CSS colour. */
color: string;
}
interface Props extends HTMLAttributes<'div'> {
/** The value shown. */
value: number;
/** The low end of the scale. */
min?: number;
/** The high end of the scale. */
max?: number;
/** What it measures, under the value (or inside a half gauge); also the meter's name. */
label?: string;
/** Text after the number, e.g. "%" or " ms". */
suffix?: string;
/** Text before the number. */
prefix?: string;
/** Decimal places of the number, 0–4. */
decimals?: number;
/** ring: a full circle from the top; arc: three quarters, open at the bottom; half: the upper half. */
shape?: 'ring' | 'arc' | 'half';
/** The drawing's width; any CSS length. */
size?: string;
/** Stroke width in the 100-unit viewBox. */
thickness?: number;
/** The value's colour. */
color?: string;
/** Colours by value: the first band whose upTo holds the value wins, over color. */
bands?: Band[];
/** The track's colour. */
track?: string;
/** Fill duration in ms. Unset, the preset's --ma-duration-slow. */
duration?: number;
/** Delay before filling, in ms. */
delay?: number;
/** Element to render. */
as?: HTMLTag;
}
const {
value,
min = 0,
max = 100,
label,
suffix = '',
prefix = '',
decimals = 0,
shape = 'ring',
size,
thickness = 9,
color,
bands,
track,
duration,
delay,
as = 'div',
class: className,
style,
...rest
} = Astro.props;
const w = Math.min(Math.max(thickness, 1), 24);
const r = 50 - w / 2;
const clamp = Math.min(Math.max(value, Math.min(min, max)), Math.max(min, max));
const f = max === min ? 1 : (clamp - min) / (max - min);
// the share of the circle each shape draws, and where it starts (degrees clockwise from three o'clock)
const span = shape === 'ring' ? 100 : shape === 'arc' ? 75 : 50;
const start = shape === 'ring' ? -90 : shape === 'arc' ? 135 : 180;
const len = +(span * f).toFixed(3);
const band = bands?.slice().sort((a, b) => a.upTo - b.upTo).find((b) => clamp <= b.upTo);
const stroke = band?.color ?? color;
const viewH = shape === 'half' ? 50 + w / 2 + 1 : 100;
const places = Math.max(0, Math.min(decimals, 4));
const text = `${prefix}${clamp.toFixed(places)}${suffix}`;
const vars = [
size && `--ma-gauge-size:${size}`,
stroke && `--ma-gauge-color:${stroke}`,
track && `--ma-gauge-track:${track}`,
duration != null && `--ma-gauge-dur:${duration}ms`,
delay != null && `--ma-gauge-delay:${delay}ms`,
`--ma-gauge-len:${len}`,
typeof style === 'string' ? style : '',
]
.filter(Boolean)
.join(';');
---
<Reveal
as={as}
from="none"
duration={300}
delay={delay}
class={['ma-gauge', className].filter(Boolean).join(' ')}
data-shape={shape}
role="meter"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={clamp}
aria-valuetext={text}
aria-label={label ?? text}
style={vars}
{...rest}
>
<svg class="ma-gauge__svg" viewBox={`0 0 100 ${viewH}`} aria-hidden="true" focusable="false">
<circle class="ma-gauge__track" cx="50" cy="50" r={r} pathLength="100" stroke-width={w} stroke-dasharray={`${span} 100`} transform={`rotate(${start} 50 50)`} />
{len > 0 && <circle class="ma-gauge__value" cx="50" cy="50" r={r} pathLength="100" stroke-width={w} stroke-dasharray={`${len} 100`} transform={`rotate(${start} 50 50)`} />}
</svg>
<span class="ma-gauge__center" aria-hidden="true">
<CountUp class="ma-gauge__number" to={clamp} decimals={places} prefix={prefix} suffix={suffix} duration={duration ?? 900} delay={delay} />
{label && <span class="ma-gauge__label">{label}</span>}
</span>
</Reveal>
<style is:global>
@layer components {
:where(.ma-gauge) {
position: relative;
display: inline-grid;
inline-size: var(--ma-gauge-size, 9rem);
max-inline-size: 100%;
color: inherit;
font-variant-numeric: tabular-nums;
}
:where(.ma-gauge__svg) {
display: block;
inline-size: 100%;
block-size: auto;
overflow: visible;
}
:where(.ma-gauge__track),
:where(.ma-gauge__value) {
fill: none;
stroke-linecap: round;
}
:where(.ma-gauge__track) {
stroke: var(--ma-gauge-track, color-mix(in oklab, currentColor 14%, transparent));
}
:where(.ma-gauge__value) {
stroke: var(--ma-gauge-color, var(--chart-1, currentColor));
stroke-dashoffset: 0;
transition: stroke-dashoffset var(--ma-gauge-dur, var(--ma-duration-slow, 700ms)) var(--ma-ease-out) var(--ma-gauge-delay, 0ms);
}
/* the number and its label sit in the middle of a ring or an arc, and on the baseline of a half */
:where(.ma-gauge__center) {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: 0.15em;
padding: 18%;
text-align: center;
line-height: 1.1;
}
:where(.ma-gauge[data-shape='half'] .ma-gauge__center) {
place-content: end center;
padding: 0 20% 0;
}
:where(.ma-gauge__number) {
font-size: calc(var(--ma-gauge-size, 9rem) * 0.19);
font-weight: 650;
letter-spacing: -0.02em;
}
:where(.ma-gauge__label) {
font-size: max(0.6875rem, calc(var(--ma-gauge-size, 9rem) * 0.085));
opacity: var(--ma-dim, 0.75);
}
/* Under the JS gate the stroke starts undrawn and grows once Reveal marks the gauge in view. */
:where([data-ma-js] .ma-gauge:not(.ma-in) .ma-gauge__value) {
stroke-dashoffset: var(--ma-gauge-len, 0);
transition: none;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-gauge__value) {
transition: none;
}
:where([data-ma-js] .ma-gauge:not(.ma-in) .ma-gauge__value) {
stroke-dashoffset: 0;
}
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
value | number | none | The value shown. A value outside min and max is held at the nearer end. |
min | number | 0 | The low end of the scale. |
max | number | 100 | The high end of the scale. |
label | string | none | What it measures, under the number; also the meter's accessible name. Unset, the name is the value. |
prefix | string | none | Text before the number. |
suffix | string | none | Text after the number, e.g. "%" or " ms". |
decimals | number | 0 | Decimal places, 0 to 4. |
shape | 'ring' | 'arc' | 'half' | 'ring' | ring: the whole circle from twelve o'clock; arc: three quarters, open at the bottom; half: the upper half, with the number on its baseline. |
size | string | '9rem' | The width of the drawing, any CSS length; the number and the label scale with it. |
thickness | number | 9 | Stroke width in the drawing's 100-unit box, 1 to 24. |
color | string | 'var(--chart-1, currentColor)' | The value's colour: the first chart colour of the theme contract, else the text colour. |
bands | { upTo: number; color: string }[] | none | Colours by value. The first band (in order of upTo) that holds the value paints it, over color. |
track | string | the text colour at 14 % | The track's colour. |
duration | number | preset (700) | Fill duration in ms; the number counts for the same time (900 ms when unset). |
delay | number | 0 | Delay in ms before it fills. |
as | HTMLTag | 'div' | Element to render. |
The gauge is drawn full and the number shown without counting.
ClientRouterInherits Reveal: rebinds after every navigation, so a gauge fills again on a fresh page.
Replaces: Gauge (Animata) · Animated Circular Progress Bar (Magic UI) · react-circular-progressbar · Donut charts for one value. See the migration table.
A bar chart whose bars grow from the baseline one after another when it scrolls into view; hovering a bar shows its value.
Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
1,284
A number whose digits roll to the new value when it changes.
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.
A small line chart that draws itself from left to right when it scrolls into view; the last point lands and pulses.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown