Gauge
A ring. It exists because a rating out of five and a completion out of one hundred are different quantities that people read the same way — as “how full is it” — and a ring answers that faster than a number alone. It is never used without the number in the centre.
Rating gauge
The colour comes from the tier the value falls in, exactly as ScoreBar derives it. It is never passed in as a prop, so a 2.1 cannot be rendered green by a caller who wanted a nicer-looking page.
An unrated event shows the track and a dash. It does not show a zero-length arc — an empty ring reads as a rating of nought, and nought is a score nobody gave. Same rule as the ScoreBar neutral.
Live
Progress gauge
The same geometry showing completion rather than quality. It is --color-primary at every value: a half-finished profile is not a bad profile, and colouring it amber would say it was.
Specification
| Property | Value | Why |
|---|---|---|
| Sizes | sm 40 · md 64 · lg 96 | Below 40 the centred figure stops being legible |
| Stroke | 4 · 6 · 8, by size | Constant ratio, so the ring reads the same at every size |
| Start | 12 o'clock, clockwise | The only starting point people predict |
| Cap | round | A square cap at a low value looks like a rendering error |
| Track | --color-border, full circle | The remainder must be visible or there is no scale |
| Rating colour | Derived from the value's tier | Never a prop — the same rule as ScoreBar |
| Tier caption | The tier word under the figure, at lg only | Below 96px there is no room for a word that is not clipped; the label still says it |
| Geometry | gaugeRing() and gaugeArc() | One source for the radius, the circumference and the dash, shared by the app and this page |
| Progress colour | --color-primary at all values | Incomplete is not bad |
| Rating role | role="img" + aria-label | It is a picture of one figure, read once |
| Progress role | role="progressbar" + aria-valuenow/min/max | It changes, and the change is the point |
role="meter" would be the literal match for a rating, and we do not use it: support across screen readers is inconsistent enough that the announcement is unreliable. role="img" with a written label says exactly what we mean — “Rating 4.7 out of 5 — excellent” — in every reader.
Rules
- Never a ring alone. The number lives in the centre. An arc without a figure is a decoration that people misread by half a point in either direction.
- Never a partial scale. The ring always represents the full range. Drawing 3.2–5.0 as a full circle is the radial version of a truncated y-axis — see Data visualisation.
- Do not animate on load beyond
--duration-base, and not at all underprefers-reduced-motion. A ring that winds up on every page load is decoration. - Never spin it. A rotating ring is a spinner, and a spinner means “waiting”. If you are waiting, use the skeleton.
- One per view. A grid of rings is a chart, and a radar or bar chart will read better.
- Use ScoreBar for comparison. Rings are hard to compare against each other; bars share a baseline. A ring is for a single headline figure.
Recipe
gauge() returns the class strings; gaugeRing() and gaugeArc() return the SVG numbers, so an app does not redo the arithmetic and the ring matches this page to the pixel. There is no React component to import: an app renders the SVG with these three. The colour comes from ratingOf() on the value as it is shown, roundRating(value), never from the raw value — otherwise 3.45 would read “3.5” in the wrong colour.
import { gauge, gaugeRing, gaugeArc, ratingOf, roundRating, formatRating } from '@sportingscouter/ui';
/* A rating. Every word (the tier names, the label sentence, "not yet rated") comes from the app, in its language. */
function RatingGauge({ value, locale, labels }) {
const shown = roundRating(value); // 4.7, or null when not rated
const tier = ratingOf(shown ?? 0); // 'excellent' … 'poor', or 'neutral'
const g = gauge({ size: 'lg', rating: tier });
const ring = gaugeRing('lg'); // the track, always
const arc = gaugeArc({ value: shown, max: 5, size: 'lg' }); // null: draw no arc
const figure = formatRating(value, locale); // "4,7", or null
return (
<span className={g.root()} role="img"
aria-label={figure ? labels.rating(figure, labels.tier[tier]) : labels.notRated}>
<svg className={g.svg()} width={ring.size} height={ring.size} viewBox={ring.viewBox} aria-hidden="true">
<circle className={g.track()} cx={ring.center} cy={ring.center} r={ring.radius}
fill="none" strokeWidth={ring.stroke} />
{arc && (
<circle className={g.arc()} cx={arc.center} cy={arc.center} r={arc.radius}
fill="none" strokeWidth={arc.stroke} strokeLinecap="round" strokeDasharray={arc.dasharray} />
)}
</svg>
<span className={g.figure()} aria-hidden="true">
{figure ?? '–'}
<span className={g.tier()}>{figure ? labels.tier[tier] : null}</span>
</span>
</span>
);
}
/* A completion: a tone instead of a rating, and role="progressbar" with aria-valuenow, min and max. */
const g = gauge({ size: 'md', tone: 'primary' });Pass rating or tone, not both; if both are passed, rating wins. With no rating, gaugeArc() returns null: draw the track and a dash, and let ratingOf(0) give neutral so the figure is muted.
theme.css.