Rating
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 — 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:
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-labelprop (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
outlineon keyboard focus viafocus-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
| Property | Token |
|---|---|
| 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-labelthat names what's being rated (e.g. "Rate how this exercise felt"), not a generic one like "Rating". - Use
readOnlyfor 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
onChangeon an interactive instance and expect clicks to do anything — without it the control is effectively read-only.
Changelog
| Date | Change |
|---|---|
| 2026-07-22 | Added 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-22 | Fixed 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-22 | Unselected 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-22 | Fixed 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-22 | Initial component created — 5-pip discrete single-select built on Radix UI's RadioGroup, with a read-only display mode and sm/md sizes |