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. The legend is the category name; the hint is what that category measures. Every category requires a score from 1 to 5.
Decisions taken from the sources, not invented
Every category requires 1–5. The group starts empty; native required then means a score. A mis-tap can be changed to another star. It cannot go back to “no score”, and that is the product rule. The chip stays as a design-system specimen only, so the empty/undo state O'Hara documented can still be tested.
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\FE0E"; } /* ☆ text */
& > label[data-star]:is(
:has(input:checked),
:has(~ label[data-star] input:checked)
) {
color: var(--color-star); /* #f59e0b */
& .star::before { content: "\2605\FE0E"; } /* ★ text */
}
}requiredWith only 1–5 in the group, native required means a score. Do not add an empty-value radio in product: that member would satisfy required without a rating. isRated() still guards controlled state before the first tap.
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 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 5, 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.
Design-system specimen
Not for product. O'Hara's “Without rating” chip is the only native way back to an empty radio group after a tap. Keep it here so empty and undo states can be tested; never render it in the shipping form.
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. Do not pass allowUnrated in product.
import { Rating } from '@sportingscouter/ui-react';
import { isRated } from '@sportingscouter/ui';
<Rating
legend="Communication"
hint="Information accessibility and clarity of instructions for participants"
required
onValueChange={setScore}
/>
{/* legend = category name, hint = what it measures.
Never a question. Same pair as ScoreBar. */}
{/* number + word, as a vertical list */}
<Rating legend="Provisions" hint="Availability and quality of event provisions" display="scale" required />
{/* numbers in a row, the word sr-only */}
<Rating legend="Merchandising" hint="Availability and quality of event merchandising, including official apparel and accessories" display="compact" required />
{/* Design-system testing only — never in product */}
<Rating legend="Specimen" allowUnrated />
{!isRated(score) && <p>Pick a rating to continue.</p>}isRated() is exported from @sportingscouter/ui. It is deliberately not reimplemented in ui-react: the threshold is a product decision, and two copies of it is one too many.
theme.css.