Primitives · Rating
Primitives

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.

How well did the organisers communicate?
No rating selected

Three decisions taken from the sources, not invented

01
"Without rating" comes first, and is checked by default

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.

02
The inputs sit over the label at full size, at opacity: 0

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

03
The tier word never appears on hover

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"; }                     /* ★ */
  }
}
What required does and does not do

In 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

displayWhen
stars · defaultA 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.
scaleA vertical list, for when the word must be read rather than recalled. Five words plus "without rating" do not fit one phone-width row.
compactA 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-live on top would say everything twice. The visible summary is for people who receive no announcement at all, and it sits outside aria-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>}
One line needed upstream

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.

Sporting Scouter Design System
Every token on this site is generated from theme.css.