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); }
<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.
<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.
| 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
| 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>, orrole="radiogroup"witharia-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
titlefor brevity; a visually hidden<span>reading "4 stars" inside each label is the more reliable choice, sincetitleis inconsistently announced and never shown on touch. -
Focus is visible, on the label rather than the input.
.rating > input:focus-visible + labeldraws 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-staticis a picture and must say so. Two rows of identical stars announce as ten graphics. Give the wrapperrole="img"and anaria-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.
<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
.rangeor 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-staticfor 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.