Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Very good

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.

Interactiveradiogroup · 1 tab stop
Read onlyrole=img · no tab stops
4.2 (1,284)

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.

Poor
Fair
Good
Very good
Excellent

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.

With count
4.6from 912 reviews
Without
5.0

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.

Empty
Rated
Full
Fractional
Read only
Small
Large
Focus
Very good
With label
No ratings yet
Not rated

Anatomy

Every part, every measurement, and the reason it is that number.

Very good

Five symbols in one radiogroup, a filled colour that is not the accent, and the word that fixes the meaning of the number.

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

  2. Gap4px

    Tight enough that five stars read as one value, wide enough that adjacent targets are not mis-tapped.

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

  4. Empty colour--ds-border-strong

    Outlined, not hidden. The unfilled stars are what make "4 of 5" readable rather than "4 stars".

  5. Text labelBeside, body-sm

    The part that makes the score portable between people. It updates live as the user hovers or arrows.

  6. Hit area44px on touch

    Padding, not a bigger symbol. On a phone, five 20px targets 4px apart is a coin flip between two scores.

  7. Fractional fillClipped overlay

    Read-only only. A user cannot deliberately choose 4.2, so fractional fills belong to aggregates and never to input.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-1Gap between symbols
touch paddingGrows each target to 44px on coarse pointers

Radius

TokenValueUsed for
--radius-xsFocus target corners

Typography

TokenValueUsed for
--text-body-smThe label beside the symbols

Motion

TokenValueUsed for
--duration-fastHover fill transition

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeIconLabel gapTypeTouch targetWhen to use
Small14px4px——Table rows, card metadata, search results. Read-only in practice.
Medium20px4px—44px paddedThe default for collecting a rating in a form.
Large28px6px—44px paddedWhen the rating is the question the page is asking.
Label—8px from the symbols13px—Always present in the interactive form.
Count——12px—Beside the average. An aggregate without a count is not comparable to anything.

Do

role="img" aria-label="4.2 out of 5"
Make the read-only version a single imageFive buttons in a product card is five tab stops for something nobody can click. role="img" with one label is the whole component.
role="radiogroup" → role="radio" aria-checked
Use a radiogroup for the interactive versionOne value, mutually exclusive, one tab stop with arrows inside. Five independent buttons announce as five unrelated controls.
Good
Put a word beside the numberYour 3 and mine differ by half a point. "Good" is the same for both of us, and it works for anyone not counting filled shapes.
4.6 from 912 reviews
Show the count with any averageA 5.0 from one person and a 4.6 from nine hundred are not the same claim, and the stars alone cannot tell them apart.

Don't

Do not make a display rating focusableFive tab stops in a card with one real action, and a screen reader announcing a form control that does nothing when activated.
10px targets
Do not allow half stars on inputNobody deliberately means 3.5 rather than 3 or 4, and hitting a 10px half-target on a phone is chance. Fractions belong to aggregates.
Was this article helpful?
Do not use five stars for a yes/no questionIt asks for precision the user does not have and cannot express. Two buttons collect the same signal with no ambiguity.
★★☆☆☆ → submitted → no follow-up field
Do not collect a score with no way to explain itA wall of 2s tells you something is wrong and nothing about what. One optional text field after the rating is where the actual information is.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.1.1Non-text ContentA1.4.1Use of ColorA2.1.1KeyboardA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

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

TabEnters the group once, on the current value. One stop for the whole rating.
← / ↓Decreases by one. → / ↑ increases.
Home / EndJumps to the lowest or highest rating.
Space / EnterSelects the focused rating. Pressing the current value again clears it, which is the only way to undo a mis-click.
1–5Optional 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.
AttributeApplied toNotes
role="radiogroup"The interactive containerWith aria-label naming what is being rated. Five buttons is the wrong model and announces wrongly.
role="radio" + aria-checkedEach symbolWith a label like "3 of 5 — Good". A bare "3" is meaningless read aloud.
role="img"The read-only versionWith one aria-label — "4.2 out of 5, from 912 reviews" — and the symbols aria-hidden.
aria-live="polite"The text labelSo the word updates audibly as the user arrows through the scale.
aria-hiddenThe individual symbols in read-only modeThey are decoration once the group carries the label.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
maxnumber5Five is the convention. Ten gives an illusion of precision nobody has.
readOnlybooleanfalseSwitches 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.
labelsstring[]—The word per value. Strongly recommended — it is what makes the score portable.

Notes

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.