# Gauge (Moonarc)

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.

- Import: `import Gauge from '@moonarc/core/Gauge'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/gauge.json`
- Tier A · category data · trigger scroll
- Readout: `<Gauge value={99.98} suffix="%" decimals={2} />`
- Browser support: newly (Chrome 91 · Firefox 128 · Safari 17); elsewhere: the gauge and its number drawn full at once
- Measured cost: 0 B JS of its own · uses Reveal + runtime (with dependencies 3.0 kB raw; CSS 9.2 kB raw)
- Page: https://moonarc.dev/components/gauge/

## Props

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

## Usage

```astro
<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)' },
  ]}
/>
```

## Reduced motion

The gauge is drawn full and the number shown without counting.

## With ClientRouter

Inherits Reveal: rebinds after every navigation, so a gauge fills again on a fresh page.

## Craft

- The value is one dash on a circle with pathLength 100, offset by its own length until the gauge is in view and then eased to zero: the stroke grows from the start of the track, and a round cap leads it.
- Arc and half rotate the same circle and draw 75 or 50 of its 100 units, so all three shapes share one geometry and one transition.
- The band is chosen on the server from the value, so the colour never passes through red on its way to green.
- The number counts on the same ease-out curve as the stroke, and both are hidden from assistive technology: the element is a meter whose name is the label and whose value text is the formatted number.
- Tabular figures keep the number from shifting sideways while it counts.

## Replaces

- Gauge (Animata)
- Animated Circular Progress Bar (Magic UI)
- react-circular-progressbar
- Donut charts for one value

## Source

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

```
