Rating

Rating

View in Storybook

A Rating is a 5-pip single-select control for rating something on a 1-5 scale — for example, how comfortable an exercise felt. It looks like a star-rating widget, but with 5 square pips in a row instead of stars, and each step has its own color from a graduated bad-to-good scale instead of a single fill color. Selection is discrete, not cumulative: picking step 3 lights up only pip 3, the rest stay neutral — it is not a magnitude fill like a star rating.


Overview​


When to use​

  • To collect a single subjective rating on a fixed 1-5 scale (e.g. "how did this exercise feel?").
  • To display a previously recorded rating as a static, read-only pip cluster (e.g. a history of past sessions shown as a row of compact dot clusters).
  • Anywhere a full-width, mobile-friendly segmented row is preferable to a native <select> or a row of radio buttons.

When not to use​

  • To represent progress through a process — use ProgressBar instead.
  • To select one option from an open-ended or non-numeric list — use Select.
  • When the value should visually accumulate (like a star rating filling up to the chosen value) — Rating is intentionally discrete, only the selected pip is colored.

States​

Unset
Selected (3)
Read-only comfort trend (5 past sessions)

Unset — value={0}, all 5 pips render neutral with a raised bevel.

Selected — the chosen pip renders with its step color (--color-rating-1 through --color-rating-5) and a sunken bevel, as if pressed in; the other 4 pips stay neutral and raised.

Read-only — pass readOnly; the control renders as a static, non-interactive display instead of a radiogroup, for showing a previously recorded rating (e.g. a comfort trend of past sessions).

Sizes are shown separately:

md — full touch-target row
sm — compact dot cluster

size="md" (default) renders full touch-target pips that stretch to fill their container — the shape used for active rating input. size="sm" renders a compact, fixed-size dot cluster — meant for read-only trend or summary displays, e.g. several past sessions' ratings shown side by side.


Labels​

Pass labels — a tuple of 5 short strings — to render text inside each pip instead of a plain color block:

<Rating
value={rating}
onChange={setRating}
labels={['Awful', 'Bad', 'Ok', 'Good', 'Nice']}
aria-label="Level of comfort"
/>

Only rendered at size="md" — there's no room for text in the compact sm dots. Each pip's accessible name includes the label too (e.g. "Good (4 of 5)").


Accessibility​

When interactive (not readOnly), Rating uses Radix UI's RadioGroup primitive under the hood:

  • Keyboard: Arrow keys move selection between pips; Tab moves focus onto and off of the group once (roving tabindex).
  • Screen readers: The group is announced using the required aria-label prop (e.g. "Rate how this exercise felt"), and each pip is announced as a radio button with its position (e.g. "3 of 5") and checked state.
  • Focus ring: Visible outline on keyboard focus via focus-visible.
<Rating value={rating} onChange={setRating} aria-label="Rate how this exercise felt" />

When readOnly, there is nothing to select, so Rating does not render a radiogroup. Instead the whole cluster gets role="img" with the given aria-label summarizing the value, and the individual pips are hidden from assistive technology:

<Rating value={4} readOnly size="sm" aria-label="Comfort rating: 4 out of 5" />

aria-label has no default and is required in both modes — always describe what is being rated.


Controls​

Interactive controls are available in Storybook. Open Storybook


Props​


Token mapping​

PropertyToken
background (unselected pip)--color-accent-primary (matches Button/Panel — recedes into the card, contour comes from the bevel only)
label text (when labels is passed)--color-text-on-accent — reads on the neutral background and all 5 rating colors alike
background (selected, step 1 — Uncomfortable)--color-rating-1
background (selected, step 2 — Somewhat off)--color-rating-2
background (selected, step 3 — Neutral)--color-rating-3
background (selected, step 4 — Comfortable)--color-rating-4
background (selected, step 5 — Excellent)--color-rating-5
bevel, unselected (top-left outer)--sunken-outer-light
bevel, unselected (bottom-right outer)--sunken-outer-dark
bevel, unselected (top-left inner)--sunken-inner-light
bevel, unselected (bottom-right inner)--sunken-inner-dark
bevel, selected (top-left outer)--sunken-outer-dark
bevel, selected (bottom-right outer)--sunken-outer-light
bevel, selected (top-left inner)--sunken-inner-dark
bevel, selected (bottom-right inner)--sunken-inner-light
focus ring--color-accent-primary (via Tailwind's outline-primary)

Do's and don'ts​

Do

  • Pass a specific, descriptive aria-label that names what's being rated (e.g. "Rate how this exercise felt"), not a generic one like "Rating".
  • Use readOnly for any display-only usage — never render an interactive radiogroup the user can't meaningfully act on.
  • Let a full-width size="md" Rating stretch to fill its container on mobile; don't wrap it in a fixed narrow box.

Don't

  • Treat the selected pip as a fill level — only one pip is ever colored, regardless of value.
  • Reuse the 5 --color-rating-* tokens for anything other than this bad-to-good comfort scale.
  • Omit onChange on an interactive instance and expect clicks to do anything — without it the control is effectively read-only.

Changelog​

DateChange
2026-07-22Added optional labels prop — short text rendered inside each pip at size="md" (e.g. "Awful"/"Bad"/"Ok"/"Good"/"Nice"), so pips don't read as empty color blocks
2026-07-22Fixed readOnly at size="md" rendering as a compact fixed-width cluster instead of the same full-width grid as the interactive version — toggling readOnly (e.g. an exercise card switching to its "complete" state) visibly shrank and left-aligned the control. Read-only now mirrors the interactive layout exactly, size for size
2026-07-22Unselected pip background changed from --color-bg-input to --color-accent-primary — it was drawing more attention than the selected state; now it matches Button/Panel and recedes, relying on the bevel alone for contour
2026-07-22Fixed steps 3 and 4 mapping to near-identical colors (--color-text-secondary/--color-accent-primary) that also matched Panel's own background, making a selected pip disappear on any card. Step 3 now uses --color-bg-page, step 4 a new --primitive-accent-success-backed value
2026-07-22Initial component created — 5-pip discrete single-select built on Radix UI's RadioGroup, with a read-only display mode and sm/md sizes