Rating

Five stars over five radio inputs. The radios are the component — they carry the value, post with the form, and give you arrow-key navigation and a group role for free. The stars are labels, and the fill is a CSS selector rather than a script.

When to use it

For collecting a rating on a small fixed scale where the reader understands the units without being told — a review, a satisfaction check after a support ticket. Stars are one of the few controls people can use without a label.

They are also imprecise. If you need to know why a rating is what it is, the stars are the easy half and the text field underneath is the part that matters.

The markup, and why it is backwards

.rating is flex-direction: row-reverse, so the first star in the DOM is drawn at the right-hand end. That means the inputs are written highest value first — 5, 4, 3, 2, 1 — and appear on screen in the order 1 to 5.

The reversal is what makes the fill work with no JavaScript. To light up a star and everything below it, the stylesheet needs a selector that reaches forward from the chosen one, because CSS has no previous-sibling combinator. Reversing the row turns "everything below" into "everything after":

.rating > input:checked ~ label,
.rating > label:hover,
.rating > label:hover ~ label { color: var(--accent-500); }
Written 5 to 1, drawn 1 to 5 — hover or use the arrow keys
<fieldset class="rating" style="border:0;padding:0;margin:0"> <input type="radio" name="dx-rate" id="dx-rate-5" value="5"> <label for="dx-rate-5" title="5 stars"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label> <input type="radio" name="dx-rate" id="dx-rate-4" value="4"> <label for="dx-rate-4" title="4 stars"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label> <input type="radio" name="dx-rate" id="dx-rate-3" value="3"> <label for="dx-rate-3" title="3 stars"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label> <input type="radio" name="dx-rate" id="dx-rate-2" value="2"> <label for="dx-rate-2" title="2 stars"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label> <input type="radio" name="dx-rate" id="dx-rate-1" value="1"> <label for="dx-rate-1" title="1 star"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label> </fieldset>
<fieldset class="rating" style="border:0;padding:0;margin:0">
  <input type="radio" name="dx-rate" id="dx-rate-5" value="5">
  <label for="dx-rate-5" title="5 stars"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label>
  <input type="radio" name="dx-rate" id="dx-rate-4" value="4">
  <label for="dx-rate-4" title="4 stars"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label>
  <input type="radio" name="dx-rate" id="dx-rate-3" value="3">
  <label for="dx-rate-3" title="3 stars"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label>
  <input type="radio" name="dx-rate" id="dx-rate-2" value="2">
  <label for="dx-rate-2" title="2 stars"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label>
  <input type="radio" name="dx-rate" id="dx-rate-1" value="1">
  <label for="dx-rate-1" title="1 star"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg></label>
</fieldset>

Get the order wrong and it fails in a specific, recognisable way: the stars fill from the right instead of the left, and the value posted is the mirror of the one clicked. If you see that, the DOM order is ascending and it needs to be descending.

Sizes

.rating-sm and .rating-lg set --star to 18px and 36px against a 26px default. It is one custom property, so a size in between is an inline style rather than a class you have to add.

Small at 5 stars, default at 3.2 — the second star row is the fill
<div class="stack stack-2"> <span class="rating-static rating-sm" style="--value:100" role="img" aria-label="5 out of 5"> <span class="rating-fill"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> </span> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> </span> <span class="rating-static" style="--value:64;--star:26px" role="img" aria-label="3.2 out of 5"> <span class="rating-fill"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> </span> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> </span> </div>
<div class="stack stack-2">
  <span class="rating-static rating-sm" style="--value:100" role="img" aria-label="5 out of 5">
    <span class="rating-fill">
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    </span>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
  </span>
  <span class="rating-static" style="--value:64;--star:26px" role="img" aria-label="3.2 out of 5">
    <span class="rating-fill">
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    </span>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
  </span>
</div>

Showing an average

An average is rarely a whole number, and radios cannot express 3.2. .rating-static solves it differently: two identical rows of stars, one grey and one coloured, with the coloured row absolutely positioned on top and clipped to calc(var(--value) * 1%). A 64% width cuts the third star part-way across.

So --value is a percentage, not a star count. Three and a fifth stars out of five is --value: 64. Multiply your average by 20.

The clipping is overflow: hidden with white-space: nowrap, so the fill row must contain the same number of stars as the row underneath or the proportions will not line up.

Classes

Generated from src/23-inputs.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.rating deck.components The inputs are written HIGHEST value first -- 5, 4, 3, 2, 1 -- and row-reverse draws them 1 to 5. That is not a quirk, it is the whole mechanism: to light a star and everything below it, the selector has to reach forward from the chosen one, and CSS has no previous-sibling combinator. Reversing the row turns "everything below" into "everything after". Get the order wrong and it fails recognisably: the stars fill from the wrong end and the posted value is the mirror of the one clicked. :focus-visible + label uses the ADJACENT sibling, so each label must come immediately after its own input. 23-inputs.css:220
.rating-fill deck.components No declarations. 23-inputs.css:255
.rating-lg deck.components Sets --star. 23-inputs.css:246
.rating-sm deck.components Sets --star. 23-inputs.css:245
.rating-static deck.components Read-only display: a fill percentage rather than whole stars 23-inputs.css:249

Tokens

Design tokens this component reads
Token Live Declared value
--accent-500 oklch(74% var(--chroma-accent) var(--hue-accent))
--ink-300 oklch(84.6% calc(var(--chroma-neutral) * 2.2) var(--hue-neutral))
--focus light-dark(var(--brand-500), var(--brand-300))
--r-xs 4px
--dur-1 110ms

--star is a component-level custom property with a 26px fallback, not a Deck token — it does not exist until .rating-sm, .rating-lg or you set it.

Accessibility

  • Real radios are the whole reason this works. Arrow keys move between options, the group is announced as a radio group, the current value is announced, and the form posts a number. None of that had to be built, and none of it can be matched by a row of <div> elements with click handlers.
  • The group needs a name. A <fieldset> with a <legend>, or role="radiogroup" with aria-label. Without it a screen reader announces five options and no question.
  • Every star needs its own text. The label contains an icon and nothing else, so its accessible name is empty. The examples use title for brevity; a visually hidden <span> reading "4 stars" inside each label is the more reliable choice, since title is inconsistently announced and never shown on touch.
  • Focus is visible, on the label rather than the input. .rating > input:focus-visible + label draws the outline, because the input itself is a 1px transparent box. It uses the adjacent-sibling combinator, so each label must come immediately after its own input — another reason the DOM order is not free-form.
  • The hover preview is pointer-only. A keyboard user sees the fill change as they arrow through, which is equivalent. A touch user gets no preview at all — the first tap commits. That is a real difference in what the three input methods show, and it argues for a text value beside the stars.
  • .rating-static is a picture and must say so. Two rows of identical stars announce as ten graphics. Give the wrapper role="img" and an aria-label — "3.2 out of 5" — and hide every icon inside, as the examples do.
  • Colour carries the value. Filled and empty stars differ only in colour — both are the same solid glyph. In greyscale, or for a reader who cannot separate the amber from the grey, the rating is unreadable. Writing the number beside it is the fix, and it is worth doing everywhere.
  • There is no way to clear a rating. Radios cannot be unchecked by clicking. If someone can rate by accident, supply a "clear" control that resets the group.

Right to left

row-reverse is resolved against the writing direction, so under dir="rtl" the row reverses again and the stars run right to left — one star at the right, five at the left. That is correct: the scale should read from the reader's starting edge.

.rating-static follows too, because its fill is anchored with inset-inline-start rather than left. The partial star is cut from the correct side without a rule in src/19-logical.css.

The fill starts at the right, which is where the scale starts
<div dir="rtl" class="stack stack-2"> <span class="rating-static" style="--value:64" role="img" aria-label="٣٫٢ من ٥"> <span class="rating-fill"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> </span> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg> </span> </div>
<div dir="rtl" class="stack stack-2">
  <span class="rating-static" style="--value:64" role="img" aria-label="٣٫٢ من ٥">
    <span class="rating-fill">
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
      <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    </span>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#star-fill"></use></svg>
  </span>
</div>

Reduced motion

A star scales to 1.14 on hover over 110ms, with the colour change on the same transition. The global reset in src/02-reset.css collapses both under prefers-reduced-motion: reduce — the star still grows, it just arrives instantly. Nothing else on the component moves.

Printing

.rating is not in src/99-print.css. The stars are inline SVG filled with currentColor, so they print as shapes rather than disappearing the way a background would — but filled and empty stars print as the same shape in two tones of grey, and on a monochrome printer they may become indistinguishable.

This is the same argument as colour carrying the value, and it has the same answer: print the number.

Overriding it

/* One control, no layer needed */
<span class="rating-static" style="--value:64;--star:22px"> … </span>

@layer app.components {
  /* Outline stars for the empty half, filled for the rated half */
  .rating > label .icon { fill: none; stroke: currentColor; }
  .rating > input:checked ~ label .icon { fill: currentColor; }

  /* A different scale colour */
  .rating > input:checked ~ label,
  .rating > label:hover,
  .rating > label:hover ~ label { color: var(--warn-500); }
}

If you re-colour the fill, change all three selectors together — they are one rule in the source and splitting them gives you a hover state that disagrees with the checked state.

When not to use it

  • Not for a scale people cannot interpret. Stars mean "how good"; they do not mean likelihood, frequency or agreement. Use labelled radios and write the ends of the scale down.
  • Not for more than about seven points. Ten stars is a row of targets nobody can hit accurately. Use a .range or a select.
  • Not for a yes-or-no. Thumbs up and down is two buttons, and pretending it is a scale invites a three-star shrug that tells you nothing.
  • Not as the only feedback you collect. A number without a reason is not actionable. The textarea underneath is the part you will read.
  • Not .rating-static for anything interactive. It is two rows of icons with no inputs. It cannot be focused, changed or posted — if someone needs to set a value, use the radio version.
  • Not where a rating can be given by accident. There is no way to unset one — see Accessibility.