One figure on a range at a glance: a credit score, a share of a limit.

Gauge, Meter or ProgressBar

A gauge is a measurement of the reader's state on a known range, which can go up or down: a credit score, a utilization, a budget spent, a savings goal saved. It is a Meter bent round: Meter for a row, Gauge for a glance. Work the system is doing, such as an upload or an import, is a ProgressBar, or a Spinner while its length is unknown. A screen reader announces a gauge as a meter, and an upload drawn as one would say the wrong thing.

A gauge is a ring, a full circle from the top, or a semicircle, an arc from the inline start over the top. It comes in three sizes: sm is 32px with its text beside it, md 96px and lg 160px with their text under them. The arc's length, its round ends included, is exactly the value's share of the range: nothing at 0, a dot under the stroke's width, half the track at 50% and the whole track at 100%.

Example

Saved
Saved
Spent this month
Follows the page theme and corners
import { Card, Gauge } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function GaugeBasic(): ReactElement {
  return (
    <Card>
      <div className="row" style={{ alignItems: 'flex-start', gap: 'var(--spacing-separate)' }}>
        {/* A 32px ring with its text beside it, then 96px and 160px with their text under them. */}
        <Gauge size="sm" label="Saved" value={62} valueLabel="62% of your goal" />
        <Gauge label="Saved" value={62} valueLabel="$6,200 of $10,000" figure="62%" />
        <Gauge
          size="lg"
          form="semicircle"
          label="Spent this month"
          value={41}
          valueLabel="$1,230 of $3,000"
          figure="41%"
          figureCaption="On track"
        />
      </div>
    </Card>
  );
}

A score

A credit score runs from 300 to 850, so minValue and maxValue set the range, and 742 draws 80.4% of the arc, not 74.2%. figure is the numeral in the middle and figureCaption the word under it; neither has a default, since a percentage would be wrong on a score. Marks at 580, 670, 740 and 800 show the band edges, and their labels are read as the gauge's description.

Your app picks the tone. Fair and Poor are both warning: a low score needs a look, and it is not a failure, so it is never error. They are told apart by the word under the figure and by where the arc stops between the marks, never by color. A good score is ink, never green: the words say it is good.

The gauge draws no numbers at its ends. The example puts the range's ends, 300 and 850, under the semicircle's ends, and a legend of the bands under the scores, so a reader can place the arc without knowing the scale. Both are the app's, in its own words and digits.

Example

Credit score

Credit score
Credit score
Credit score
  • Poor 300 to 579
  • Fair 580 to 669
  • Good 670 to 739
  • Very good 740 to 799
  • Excellent 800 to 850
Follows the page theme and corners
import { Card, CardBody, CardHeader, CardTitle, Gauge, type GaugeMark } from '@snapshot-ui/react';
import type { CSSProperties, ReactElement } from 'react';

/** The band edges of a 300 to 850 score: each mark's label is read as the gauge's description. */
const BANDS: GaugeMark[] = [
  { value: 580, label: 'Fair from 580' },
  { value: 670, label: 'Good from 670' },
  { value: 740, label: 'Very good from 740' },
  { value: 800, label: 'Excellent from 800' },
];

/** The bands in words, for the legend under the scores: the arc's place between the marks, said once. */
const LEGEND = [
  { word: 'Poor', range: '300 to 579' },
  { word: 'Fair', range: '580 to 669' },
  { word: 'Good', range: '670 to 739' },
  { word: 'Very good', range: '740 to 799' },
  { word: 'Excellent', range: '800 to 850' },
];

/** The app picks the word and the tone: ink from 670, warning under it, never error, which means a failure. */
function band(score: number): { caption: string; tone: 'neutral' | 'warning' } {
  if (score >= 800) return { caption: 'Excellent', tone: 'neutral' };
  if (score >= 740) return { caption: 'Very good', tone: 'neutral' };
  if (score >= 670) return { caption: 'Good', tone: 'neutral' };
  if (score >= 580) return { caption: 'Fair', tone: 'warning' };
  return { caption: 'Poor', tone: 'warning' };
}

/**
 * The range's ends, under the semicircle's ends: an lg dial is 160px wide and centred in the gauge, and its arc
 * ends 80px down, so each figure sits just under its end. The meter reports the range already, so these repeat it
 * for the eye only.
 */
const END: CSSProperties = {
  position: 'absolute',
  insetBlockStart: 'calc(5rem + var(--spacing-part))',
  fontSize: 'var(--text-small)',
  lineHeight: 'var(--text-small--line-height)',
  fontVariantNumeric: 'tabular-nums',
  color: 'var(--color-slate)',
};

function Score({ score }: { score: number }): ReactElement {
  const { caption, tone } = band(score);
  return (
    <div style={{ position: 'relative', display: 'inline-grid' }}>
      <Gauge
        label="Credit score"
        form="semicircle"
        size="lg"
        minValue={300}
        maxValue={850}
        value={score}
        valueLabel={`${String(score)} of 850, ${caption}`}
        figure={String(score)}
        figureCaption={caption}
        tone={tone}
        marks={BANDS}
      />
      <span aria-hidden="true" style={{ ...END, insetInlineStart: 'calc(50% - 5rem)' }}>
        300
      </span>
      <span aria-hidden="true" style={{ ...END, insetInlineEnd: 'calc(50% - 5rem)' }}>
        850
      </span>
    </div>
  );
}

export default function GaugeScore(): ReactElement {
  return (
    <Card>
      <CardHeader>
        <CardTitle level={3}>Credit score</CardTitle>
      </CardHeader>
      <CardBody>
        <div className="stack">
          <div className="row" style={{ alignItems: 'flex-start', gap: 'var(--spacing-separate)' }}>
            <Score score={742} />
            {/* Fair and Poor are both warning: the word and where the arc stops between the marks tell them apart. */}
            <Score score={640} />
            <Score score={540} />
          </div>
          <ul
            className="row"
            style={{
              margin: 0,
              padding: 0,
              listStyle: 'none',
              columnGap: 'var(--spacing-peer)',
              fontSize: 'var(--text-small)',
              lineHeight: 'var(--text-small--line-height)',
            }}
          >
            {LEGEND.map(({ word, range }) => (
              <li key={word}>
                <span style={{ fontWeight: 500 }}>{word}</span>{' '}
                <span style={{ color: 'var(--color-slate)', fontVariantNumeric: 'tabular-nums' }}>
                  {range}
                </span>
              </li>
            ))}
          </ul>
        </div>
      </CardBody>
    </Card>
  );
}

A share of a limit

A utilization runs from 0 to 100, the defaults. Marks at 10% and 30% show where a reader should stay under. warning says a state needs a look; above maxValue a neutral gauge draws warning by itself, its arc stops at the end, and valueLabel must say "over". error is only for a limit that stopped something, such as a declined payment.

Example

Overall utilization
Travel Mastercard
Store card
Everyday Visa
Follows the page theme and corners
import { Card, Gauge, type GaugeMark } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

const MARKS: GaugeMark[] = [
  { value: 10, label: 'Best under 10%' },
  { value: 30, label: 'Keep it under 30%' },
];

export default function GaugeUtilization(): ReactElement {
  return (
    <Card>
      <div className="row" style={{ alignItems: 'flex-start', gap: 'var(--spacing-separate)' }}>
        <Gauge
          label="Overall utilization"
          value={23}
          valueLabel="23% of your limits"
          figure="23%"
          marks={MARKS}
        />
        {/* A state that needs a look: warning, and the words say why. */}
        <Gauge
          label="Travel Mastercard"
          tone="warning"
          value={47}
          valueLabel="47% of its limit, above 30%"
          figure="47%"
          marks={MARKS}
        />
        {/* Over the limit a neutral gauge draws warning by itself; the words must say "over". */}
        <Gauge
          label="Store card"
          value={104}
          valueLabel="104% of its limit, over"
          figure="104%"
          marks={MARKS}
        />
        {/* Error only where the limit stopped something. */}
        <Gauge
          label="Everyday Visa"
          tone="error"
          value={100}
          valueLabel="At its limit, a payment was declined"
          figure="100%"
          marks={MARKS}
        />
      </div>
    </Card>
  );
}

In a table row

A 32px ring fits a DataTable row in every density. The column header names the cell, so the gauge takes aria-label with the row's name, "Utilization, Everyday Visa ending 4821", and shows only its value text beside it. On a phone the ring moves to the item's meta line. A 32px gauge holds two or three marks; marks closer than the stroke's width overlap.

The marks are the same on every row, so give them an empty label: a mark with no words is drawn and not read, and a screen reader does not hear the thresholds again on each row. Say them once, in a note by the table, as the example does. A mark's gap is cut out of the arc, so the row's own fill shows through it, hovered or selected too.

Example

Utilization marks: best under 10%, and keep it under 30%.

CardUtilizationBalance
Everyday Visa ending 4821
$1,150.00
Travel Mastercard ending 0937
$2,350.00
Store card ending 5512
$40.00
Everyday Visa ending 4821
$1,150.00
Travel Mastercard ending 0937
$2,350.00
Store card ending 5512
$40.00
Follows the page theme and corners
'use client';

import { DataTable, Gauge, type DataTableColumn } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

interface CardRow {
  id: string;
  name: string;
  balance: string;
  used: number;
}

const CARDS: CardRow[] = [
  { id: 'visa', name: 'Everyday Visa ending 4821', balance: '$1,150.00', used: 23 },
  { id: 'master', name: 'Travel Mastercard ending 0937', balance: '$2,350.00', used: 47 },
  { id: 'store', name: 'Store card ending 5512', balance: '$40.00', used: 4 },
];

/**
 * The two thresholds, drawn on every row. Their words would be read again on each row, so the marks carry none
 * and the note above the table says them once.
 */
const MARKS = [
  { value: 10, label: '' },
  { value: 30, label: '' },
];

const COLUMNS: readonly DataTableColumn<CardRow>[] = [
  { id: 'name', header: 'Card', cell: (card) => card.name, isRowHeader: true },
  {
    id: 'utilization',
    header: 'Utilization',
    // The column header names the cell, so the gauge takes the row's name and shows only its value text.
    cell: (card) => (
      <Gauge
        aria-label={`Utilization, ${card.name}`}
        size="sm"
        value={card.used}
        valueLabel={card.used > 30 ? `${String(card.used)}%, above 30%` : `${String(card.used)}%`}
        tone={card.used > 30 ? 'warning' : 'neutral'}
        marks={MARKS}
      />
    ),
    textValue: (card) => `${String(card.used)}%`,
    listSlot: 'meta',
  },
  { id: 'balance', header: 'Balance', cell: (card) => card.balance, align: 'end', listSlot: 'end' },
];

export default function GaugeTable(): ReactElement {
  return (
    <div className="stack" style={{ width: '100%', gap: 'var(--spacing-part)' }}>
      <p className="copy copy-small" style={{ margin: 0 }}>
        Utilization marks: best under 10%, and keep it under 30%.
      </p>
      <DataTable aria-label="Cards" items={CARDS} columns={COLUMNS} getKey={(card) => card.id} />
    </div>
  );
}

In a tile

A 32px ring fits a StatTile's trend, its 96 by 32 slot. Name it, since the tile's words do not say the share.

Example

Credit used

$1,150.00

Of $5,000 across your cards

Follows the page theme and corners
import { Gauge, StatTile } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function GaugeTile(): ReactElement {
  return (
    <div className="tile-row">
      <StatTile
        label="Credit used"
        value="$1,150.00"
        caption="Of $5,000 across your cards"
        // A 32px ring fits the trend's 96 by 32 slot, named since the tile's words do not say the share.
        trend={
          <Gauge
            aria-label="Credit used, share of your limits"
            size="sm"
            value={23}
            valueLabel="23%"
            marks={[
              { value: 10, label: 'Best under 10%' },
              { value: 30, label: 'Keep it under 30%' },
            ]}
          />
        }
      />
    </div>
  );
}

Usage

The gauge is static: a server component renders it with no client JavaScript, and its numbers are arithmetic on its props, the same on the server and in the browser. Pass every figure and word as a string; the library formats no numbers.

  • minValue and maxValue set the range, 0 and 100 by default. A value outside it is drawn clamped.
  • valueLabel says the state in words and is what a screen reader hears. At a warning or error tone, or over the limit, it says so.
  • figure and figureCaption draw the numeral and its word at md and lg; there are none by default and none at sm. A figure never wraps or shrinks: keep it to 4 characters at md and 6 at lg, such as "100%" or "$12.3K". In a ring the caption sits in the circle where it narrows, so at md it is one line of about 60px, a word or two, and at lg two lines of about 104px; in a semicircle it is two lines under the chord. Past that it ends in an ellipsis, and the value text carries the words whole.
  • tone is neutral, warning or error, your choice; there is no success tone.
  • marks takes up to five; a sixth is not drawn. A mark with an empty label is drawn and not read.
  • The arc moves over 200ms when the value changes, to and from 0 too, never on first appearance, and not at all under reduced motion. It follows the page's direction: on a right-to-left page a ring runs counter-clockwise and a semicircle fills from the right.
  • Each mark stands in a gap cut out of the track and the arc, so whatever is behind the gauge shows there: a card, the page, a hovered or selected table row. It needs no surface color. --gauge-size on the gauge draws it at another size.
import { Gauge } from '@snapshot-ui/react';

<Gauge
  label="Credit score"
  form="semicircle"
  size="lg"
  minValue={300}
  maxValue={850}
  value={742}
  valueLabel="742 of 850, Very good"
  figure="742"
  figureCaption="Very good"
  marks={[
    { value: 580, label: 'Fair from 580' },
    { value: 670, label: 'Good from 670' },
    { value: 740, label: 'Very good from 740' },
    { value: 800, label: 'Excellent from 800' },
  ]}
/>;

Props

Gauge accepts every HTML element attribute, on its root. The name (aria-label or aria-labelledby) and aria-describedby go on the dial.

className
string

figure
string

The short numeral in the middle at md and lg: "742", "23%". At most 4 characters at md and 6 at lg. None when absent.

figureCaption
string

A word under the figure at md and lg: "Very good". It repeats what valueLabel says.

form
"ring" | "semicircle"

ring is a full circle from the top; semicircle an arc from the inline start over the top to the end.

Default: 'ring'

label
ReactNode

The visible name: "Credit score", "Utilization". Without it, aria-label or aria-labelledby names the gauge.

marks
readonly GaugeMark[]

Up to five marks across the track: band edges, a threshold, a target. A sixth is not drawn.

maxValue
number

The range's end: 850 for a credit score, 100 for a percentage.

Default: 100

minValue
number

The range's start, in the same units: 300 for a credit score.

Default: 0

size
"lg" | "md" | "sm"

32, 96 or 160px.

Default: 'md'

tone
"error" | "neutral" | "warning"

What the value means now, the app's choice: warning needs a look soon, such as a fair or a poor score or a utilization above 30%; error is a failure only, such as a limit that stopped a payment, never a score band. There is no success tone: a good value is ink, and its words say it is good. Above maxValue a neutral gauge draws as warning.

Default: 'neutral'

value
number

The measurement, in the range's units. Drawn clamped to the range; above maxValue the gauge is over its limit.

valueLabel
string

The value in the app's words, shown under or beside the dial and given as the accessible value: "742 of 850, Very good", "23% of your limit". At a warning or error tone, or over the limit, it must say so.

label
string

What the mark means, added to the gauge's description: "Good from 670". An empty label draws the mark and adds nothing, for thresholds repeated on every row of a table and said once beside it.

value
number

In the gauge's units, between minValue and maxValue: 670 on a score, 30 on a utilization.

Accessibility

The dial is a role="meter" from minValue to maxValue, named by the label, aria-label or aria-labelledby.

  • Its value is read as valueLabel, never as a number on its own. The visible value text, the figure and its caption are hidden, since the meter reads the same words, and the same said twice is noise.
  • The marks' labels, in order of value, are read as its description.
  • It takes no focus and has no keys.
  • Chromium, Firefox and WebKit expose the dial to the platform's accessibility interface as a meter with its name, its value text and its description. What a screen reader then says for the role differs between readers and versions and is not yet verified here, so the value text carries the state in words.

Design rules

What keeps gauges in the system.

Say the state in words in the value text, and repeat it under the figure.

Mark the edges of the bands the reader cares about.

Keep the figure short: 4 characters at md, 6 at lg.

Never color a good value success. The words say it is good.

Never use a gauge for work getting done. That is a progress bar.

Never pass a figure longer than its dial holds. Shorten it.

esc

↑ ↓ to move↵ to open