Rating
Collecting or displaying a score. Read-only and interactive share an anatomy and almost nothing else — one is an input, the other is output.
Also called Stars, Score, NPS — in this system all of them are Rating.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Input and output are different components
The same shape doing two jobs. One is a radiogroup with a tab stop; the other is an image with a label and no focus at all.
Words make the scale mean something
Without the label, your 3 and mine are different scores. With it, both of us mean “Good” — and the meaning survives for anyone not counting filled stars.
Aggregates need the count
A 5.0 from one person and a 4.6 from nine hundred are not comparable. The count is what makes an average mean anything.
Sizes
Small for a table row or a card, medium for a form, large when the rating is the question the page is asking.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Five symbols in one radiogroup, a filled colour that is not the accent, and the word that fixes the meaning of the number.
- Symbol size14 / 20 / 28px
Below 14px the fill state stops being legible at a glance, which is the only thing the symbol has to communicate.
- Gap4px
Tight enough that five stars read as one value, wide enough that adjacent targets are not mis-tapped.
- Filled colour--ds-warning
Deliberately not the accent. A rating is not a primary action, and amber is the colour users already associate with a score.
- Empty colour--ds-border-strong
Outlined, not hidden. The unfilled stars are what make "4 of 5" readable rather than "4 stars".
- Text labelBeside, body-sm
The part that makes the score portable between people. It updates live as the user hovers or arrows.
- Hit area44px on touch
Padding, not a bigger symbol. On a phone, five 20px targets 4px apart is a coin flip between two scores.
- Fractional fillClipped overlay
Read-only only. A user cannot deliberately choose 4.2, so fractional fills belong to aggregates and never to input.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-warning | — | Filled symbol |
| --ds-border-strong | — | Empty symbol outline |
| --ds-fg-secondary | — | The text label |
| --ds-fg-muted | — | Review count and the not-rated state |
| --ds-focus-ring | — | Focus outline on the active symbol |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-1 | Gap between symbols | |
| touch padding | Grows each target to 44px on coarse pointers |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xs | Focus target corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-body-sm | The label beside the symbols |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Hover fill transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Icon | Label gap | Type | Touch target | When to use |
|---|---|---|---|---|---|
| Small | 14px | 4px | — | — | Table rows, card metadata, search results. Read-only in practice. |
| Medium | 20px | 4px | — | 44px padded | The default for collecting a rating in a form. |
| Large | 28px | 6px | — | 44px padded | When the rating is the question the page is asking. |
| Label | — | 8px from the symbols | 13px | — | Always present in the interactive form. |
| Count | — | — | 12px | — | Beside the average. An aggregate without a count is not comparable to anything. |
role="img" aria-label="4.2 out of 5"role="radiogroup" → role="radio" aria-checkedNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Filled and empty symbols must differ by more than colour. The fill itself is the second signal, which is why empty stars are outlined rather than a paler amber.
- The empty outline owes 3:1 against the background — it defines the scale’s length.
- The text label owes 4.5:1, and it is what carries the meaning when the symbols cannot be seen at all.
- In forced-colors mode the fill collapses. Provide a border or shape difference so filled and empty stay distinguishable.
Keyboard
| Tab | Enters the group once, on the current value. One stop for the whole rating. |
| ← / ↓ | Decreases by one. → / ↑ increases. |
| Home / End | Jumps to the lowest or highest rating. |
| Space / Enter | Selects the focused rating. Pressing the current value again clears it, which is the only way to undo a mis-click. |
| 1–5 | Optional direct entry. Cheap to add and the fastest path for anyone who knows their answer. |
Screen readers
- The interactive group announces as "Deployment experience, radio group" then "4 of 5 — Very good, selected".
- The read-only version announces as one string: "4.2 out of 5, from 912 reviews". Never as five separate images.
- Announce the word, not just the number, when the value changes. "Very good" is more informative than "4" and takes no longer to say.
Focus & touch
- Roving tabindex across the group: only the current value is tabbable. After selection focus stays on the chosen symbol so the user can immediately adjust — moving focus to the next field on selection removes the chance to correct.
- Five 20px targets 4px apart is a coin flip on a phone. Add padding to reach 44px per symbol — the symbols stay the same size, the targets grow and overlap the gaps. Do not implement drag-across-to-rate: it conflicts with page scrolling and gives no way to confirm the value before releasing.
| Attribute | Applied to | Notes |
|---|---|---|
| role="radiogroup" | The interactive container | With aria-label naming what is being rated. Five buttons is the wrong model and announces wrongly. |
| role="radio" + aria-checked | Each symbol | With a label like "3 of 5 — Good". A bare "3" is meaningless read aloud. |
| role="img" | The read-only version | With one aria-label — "4.2 out of 5, from 912 reviews" — and the symbols aria-hidden. |
| aria-live="polite" | The text label | So the word updates audibly as the user arrows through the scale. |
| aria-hidden | The individual symbols in read-only mode | They are decoration once the group carries the label. |
Example usage
1import { Rating } from '@/ui/Input'23// Interactive: a radiogroup, with the word that fixes the meaning.4<Field label="How was your deployment experience?">5 <Row>6 <Rating7 label="Deployment experience"8 value={score}9 onChange={setScore}10 />11 <span aria-live="polite">{LABELS[score] ?? 'Not rated'}</span>12 </Row>13</Field>1415{/* The score alone tells you something is wrong and nothing about what. */}16{score > 0 && score <= 3 && (17 <Field label="What went wrong?" optional>18 <Textarea rows={3} />19 </Field>20)}2122// Read-only: one image, one label, zero tab stops.23<span role="img" aria-label={`${avg} out of 5, from ${count} reviews`}>24 {Array.from({ length: 5 }, (_, i) => (25 <Star key={i} aria-hidden fill={fillFor(avg, i)} />26 ))}27</span>2829// Fractional fill for aggregates: clip an overlay rather than swapping icons.30function PartialStar({ fill }: { fill: number }) {31 return (32 <span className="relative">33 <Star className="text-[var(--ds-border-strong)]" />34 <span className="absolute inset-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>35 <Star className="text-[var(--ds-warning)]" fill="currentColor" />36 </span>37 </span>38 )39}Framework-free HTML
<!-- Interactive: one value, one tab stop, arrows inside. -->
<div role="radiogroup" aria-label="Deployment experience">
<button type="button" role="radio" aria-checked="false" tabindex="-1"
aria-label="1 of 5 — Poor">★</button>
<button type="button" role="radio" aria-checked="false" tabindex="-1"
aria-label="2 of 5 — Fair">★</button>
<button type="button" role="radio" aria-checked="true" tabindex="0"
aria-label="3 of 5 — Good">★</button>
</div>
<p role="status" aria-live="polite">Good</p>
<!-- Read-only: NOT five buttons. One image, one label. -->
<span role="img" aria-label="4.2 out of 5, from 912 reviews">
<svg aria-hidden="true">…</svg>
<svg aria-hidden="true">…</svg>
<svg aria-hidden="true">…</svg>
<svg aria-hidden="true">…</svg>
<svg aria-hidden="true">…</svg>
</span>CSS
.ds-rating {
display: inline-flex;
align-items: center;
gap: 4px;
}
.ds-rating button {
padding: 2px;
border-radius: var(--radius-xs);
color: var(--ds-border-strong); /* empty: outlined, 3:1, still visible */
transition: color 120ms;
}
.ds-rating button[aria-checked='true'],
.ds-rating button[data-active='true'] {
color: var(--ds-warning); /* not the accent: this is not an action */
}
/* Fill is the second signal, so filled/empty survives greyscale. */
.ds-rating svg[data-filled='true'] { fill: currentColor; }
/* The symbols stay 20px; the TARGETS grow to 44px and swallow the gaps. */
@media (pointer: coarse) {
.ds-rating button { padding: 12px; margin: -12px; }
.ds-rating { gap: 28px; }
}
/* Colour collapses in forced-colors: keep a shape difference. */
@media (forced-colors: active) {
.ds-rating button[aria-checked='true'] { forced-color-adjust: none; }
}
.ds-rating__partial { position: relative; display: inline-block; }
.ds-rating__partial > .fill {
position: absolute;
inset: 0;
overflow: hidden; /* clip to the fraction */
color: var(--ds-warning);
}Component API
Rating
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | number | — | 0 means not rated, which must be distinguishable from 1. |
| onChange | (v: number) => void | — | Omit for the read-only form, which renders as one image with no tab stops. |
| label* | string | — | Names what is being rated. Becomes the group label or the image label. |
| max | number | 5 | Five is the convention. Ten gives an illusion of precision nobody has. |
| readOnly | boolean | false | Switches to role="img", removes every tab stop, and enables fractional fills. |
| size | 'sm' | 'md' | 'lg' | 'md' | Small is read-only in practice; its targets are too tight for input. |
| labels | string[] | — | The word per value. Strongly recommended — it is what makes the score portable. |
Professional tips
- Follow a low rating with an optional text field, shown conditionally. The score tells you something is wrong; the sentence tells you what.
- Let a user clear their rating by pressing the current value again. Without it, a mis-click is permanent and they will abandon the form.
- Round displayed averages to one decimal place. Two implies a precision that a five-point scale does not have.
- Show the distribution, not just the average, wherever there is room. A 3.0 made of 1s and 5s is a completely different product from a 3.0 made of 3s.
- Never pre-select a default. A pre-filled rating collects the default from everyone who does not notice it.
Performance
- Render read-only ratings as inline SVG rather than an icon component per star. In a list of two hundred products that is a thousand components for something that never changes.
- Memoise the fill calculation on the value. It runs once per symbol per render and is trivially cacheable.
- Submit optimistically and reconcile. A rating is low-stakes, and waiting for a round trip to show the star filled makes the control feel broken.
Common mistakes
- Focusable stars in a read-only display, adding five tab stops per card.
- Five independent buttons instead of a radiogroup, announcing as unrelated controls.
- Half-star input, where a 10px target is chance rather than choice.
- No text label, so the number means something different to every person.
- An average with no count, which cannot be compared to anything.
- No way to clear a rating, making a mis-click permanent.
- Colour as the only difference between filled and empty, which disappears in greyscale and in forced-colors mode.
Real-world recommendations
- Star ratings skew high almost everywhere — most distributions are bimodal at 5 and 1. Treat anything below 4 as a strong negative signal rather than a middling one.
- Response rates fall sharply as the scale grows. Five options collect more answers than ten, and the extra resolution is noise anyway.
- Asking immediately after an interaction gets several times the response rate of asking later. The rating belongs at the end of the flow, not in an email.
- For internal tools, thumbs up/down usually beats stars outright: the question is almost always "did this work?", which has two answers.