Rating
The rating input — distinct from ScoreBar, which is the output. A native radio group implementing the W3C ARIA APG rating pattern, in Scott O'Hara's styled-radio variant, at WCAG 1.4.11 contrast.
Three decisions taken from the sources, not invented
Radios have no native way back to fully-unchecked without a form reset or a page refresh. Without this option, one mis-tap traps the respondent. Declining to rate is a legitimate answer, not a failure to answer — burying it at the bottom pressures people into inventing a number.
opacity: 0Not moved off-screen. Hiding a control off-screen means anyone searching by touch, or hovering with a screen reader announcing as they go, never finds it.
Revealing text on hover or focus pulls WCAG 1.4.13 (dismissable, hoverable, persistent) into scope and requires an Esc handler. The alternative the sources name is a static location that updates with the selection — our summary line.
Shape, not colour
Filled and empty are two different glyphs — ★ and ☆ — carried by a ::before. In forced-colors mode every colour is replaced by the system palette, and a colour-only fill would make full and empty stars identical. As a text glyph it also inherits currentcolor for free.
Cumulative fill is a property of the group, not of one input, so it is expressed once in the stars utility. :is() equalises specificity between the checked and hover rules — without it the checked rule outranks hover and the preview breaks the moment you hover a filled star.
@utility stars {
& > label[data-star] { color: var(--color-text-muted); }
& > label[data-star] .star::before { content: "\2606"; } /* ☆ */
& > label[data-star]:is(
:has(input:checked),
:has(~ label[data-star] input:checked)
) {
color: var(--color-star);
& .star::before { content: "\2605"; } /* ★ */
}
}required does and does not doIn a radio group, required is satisfied by any member — including "Without rating". Native validation cannot express "1 to 5 only". If the answer must be a score, that is application validation, and the message must state what is actually enforced.
Display variants
| display | When |
|---|---|
stars · default | A public survey. The convention is what a tired athlete recognises without reading. The number sits beside each star; the tier word goes to the summary, so the control is never shape alone. |
scale | A vertical list, for when the word must be read rather than recalled. Five words plus "without rating" do not fit one phone-width row. |
compact | A row of numbers with the word in sr-only, for dashboard grids where the words live in the column header. |
Judgement calls, owned as such
- The input is never tier-coloured. A green 5 next to a red 1 tells the respondent which answer is correct. That is survey methodology, not an accessibility rule.
- 44px targets in every density, where WCAG asks 24px at AA and 44px at AAA.
- No live region. The radio group already announces "option 4 of 6, Very Good, selected". An
aria-liveon top would say everything twice. The visible summary is for people who receive no announcement at all, and it sits outsidearia-describedby. - Invalid outlines the group, not each star. Stars have no border to turn red, and framing each one would read as five separate controls.
React
From @sportingscouter/ui-react. The component adds no visual values of its own — every class comes from the same tv() recipe the design system defines, so the two cannot drift.
import { Rating } from '@sportingscouter/ui-react';
import { isRated } from '@sportingscouter/ui'; /* see note below */
<Rating
legend="How was the water on course?"
onValueChange={setScore}
/>
{/* "No rating" comes FIRST and is checked by default —
radios have no native way back to fully-unchecked, so a
mis-tap would otherwise be permanent. */}
{/* number + word, as a vertical list */}
<Rating legend="Signage" display="scale" />
{/* numbers in a row, the word sr-only */}
<Rating legend="Medal" display="compact" />
{/* A required rating must be 1–5: "No rating" does not
satisfy it. Native required is met by any radio in the
group, so the form checks it. */}
{!isRated(score) && <p>Give this a score before submitting.</p>}isRated() lives in ui/src/primitives/rating.ts but is not re-exported from the barrel yet, so the import above needs isRated adding to the ./primitives/rating export in ui/src/index.ts. It is deliberately not reimplemented in ui-react: the 1–5 threshold is a product decision, and two copies of it is one too many.
theme.css.