Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Star Rating

A CSS star rating gives you a clickable form control that submits ratings, with hover previews, keyboard access, and options for SVG icons.

Level
Intermediate
Published
Updated
Reading time
8 min

Try it

3 live demos

Click a star to rate

Other ways to build it

Natural markup order with :has(), and a keyboard focus ring

See the code

Build it

6 snippets to copy

CSS Star Rating

HTML

<div class="stars">
  <input type="radio" name="rating" id="s5" value="5"><label for="s5">★</label>
  <input type="radio" name="rating" id="s4" value="4"><label for="s4">★</label>
  <input type="radio" name="rating" id="s3" value="3"><label for="s3">★</label>
  <input type="radio" name="rating" id="s2" value="2"><label for="s2">★</label>
  <input type="radio" name="rating" id="s1" value="1"><label for="s1">★</label>
</div>

CSS

.stars {
  display: inline-flex;
  flex-direction: row-reverse;
  gap: .25rem;
}

.stars input {
  display: none;
}

.stars label {
  font-size: 2rem;
  color: #88888f;
  cursor: pointer;
  transition: color .15s;
}

.stars input:checked ~ label,
.stars label:hover,
.stars label:hover ~ label {
  color: #ffd444;
}

Read only rating with a fractional value

An average rating is rarely a whole number, so a display-only version stacks two rows of stars and clips the top one to a percentage. The gray row underneath is the full five, the yellow row on top is cut off at the rating divided by five. Setting the value as a custom property keeps the arithmetic in the stylesheet, so the markup carries the number and nothing else. This is output, not a control, so it uses role="img" and an aria-label that states the score in words.

HTML

<div class="star-static" style="--star-value:3.7"
     role="img" aria-label="Rated 3.7 out of 5">
  <span class="star-static-fill"></span>
</div>

CSS

.star-static {
  position: relative;
  display: inline-block;
  font-size: 2rem;
  line-height: 1;
  color: #2a2a2d;
}

.star-static::before {
  content: "★★★★★";
}

.star-static-fill {
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
  white-space: nowrap;
  color: #ffd444;
  /* 3.7 out of 5 becomes 74% */
  width: calc(var(--star-value) / 5 * 100%);
}

.star-static-fill::before {
  content: "★★★★★";
}

Natural markup order with :has(), and a keyboard focus ring

Reversing the row is a workaround for a selector that only looks forward. :has() looks backward, so label:has(~ input:checked) matches every star written before the checked one and the markup can stay in ascending order. Arrow keys then move left to right the way the row is drawn. The inputs are clipped rather than removed so they keep their place in the tab order, each label carries a hidden text name so each radio is named 1 star through 5 stars, and the checked fill is wrapped in :not(:hover) so an old value does not linger behind a hover preview. Tab into the row and use the arrow keys.

HTML

<div class="star-forward">
  <input type="radio" name="rate" id="r1" value="1">
  <label for="r1">
    <span aria-hidden="true">★</span>
    <span class="star-name">1 star</span>
  </label>
  <!-- r2 through r5 follow in ascending order -->
</div>

CSS

.star-forward {
  display: inline-flex;
  position: relative;
}

/* clipped, not display:none, so arrow keys still reach the group */
.star-forward input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
}

.star-forward label {
  font-size: 2rem;
  line-height: 1;
  padding: 0 .12rem;
  color: #88888f;
  cursor: pointer;
  transition: color .15s;
}

/* the glyph is decorative; this text is the accessible name */
.star-forward .star-name {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* :has() selects backwards, so no reversed markup is needed */
.star-forward:not(:hover) input:checked + label,
.star-forward:not(:hover) label:has(~ input:checked) {
  color: #ffd444;
}

.star-forward label:hover,
.star-forward label:has(~ label:hover) {
  color: #ffd444;
}

.star-forward input:focus-visible + label {
  outline: 2px solid #b8ff57;
  outline-offset: 2px;
  border-radius: 4px;
}

Understand it

How it works and 6 properties

A CSS star rating is five radio buttons wearing star glyphs. The radios hold the value, the labels are what you see, and the general sibling combinator fills in every star up to the one that is checked. There is no script behind it, and because the control is still a radio group it submits with a form and validates like any other field.

The awkward part is direction. CSS sibling combinators only select forward, and a rating needs to fill backwards from the star you clicked. The classic answer is to reverse the row with flex-direction: row-reverse and write the radios from five down to one. Below that are two more versions: a read only display that shows a fractional average, and one that uses :has() to keep the markup in natural order so the arrow keys behave.

How it works

Radio inputs are reversed with flex-direction:row-reverse so that input:checked ~ label can select every star to the left of the checked one. Hovering uses the same sibling trick. The inputs themselves are hidden, and only their labels (the ★ characters) are visible.

The five inputs share a name, which is what makes them one group where checking any star clears the others. Each has a value from 1 to 5, so the form posts a number rather than anything the stylesheet invented. Each label points at its input with for, and that pairing is doing two jobs: it is what makes clicking a star select it, and it is what gives the radio an accessible name.

input:checked ~ label is the general sibling combinator. It matches every label that comes after the checked input in the markup, at the same level. In source order that means the lower numbered stars, since the radios are written from five down to one. flex-direction: row-reverse then paints that source order right to left, so the run of highlighted stars appears on the left of the row where a reader expects it.

Hover uses the same shape twice. label:hover colors the star under the pointer and label:hover ~ label colors everything after it in source order, which is everything to its left on screen. Both rules are declared after the checked rule, so a hover preview temporarily paints over the current value. That is also the weakness of the simple version: if a reader has already picked five and then hovers over two, stars three to five stay lit, because the checked rule is still matching them.

Reversing the row solves the CSS problem and creates a keyboard one. Arrow keys move through a radio group in DOM order, so pressing the right arrow moves from five to four, which on screen is a jump to the left. Anyone driving the widget with a keyboard gets movement that runs backwards from the arrow they pressed. The :has() variant below fixes it by leaving the markup in ascending order and selecting backwards instead.

The same radio group mechanism appears on custom radio buttons and, with checkboxes rather than radios, on custom checkboxes. Anywhere one choice out of several has to stick without script, this is the pattern underneath it.

Accessibility notes

display: none on the inputs is the problem to fix first. It takes all five radios out of the tab order and out of the accessibility tree, so the rating cannot be set with a keyboard. Clip the inputs to a single pixel instead. They stay focusable, arrow keys still move through the group, and the focus ring can be forwarded to the label with input:focus-visible + label.

A star glyph is a poor accessible name. Put the real name in the label as text, hide it visually, and mark the glyph aria-hidden="true". The second variant does that, and each radio is named 1 star, 2 stars and so on.

Color is the only thing separating a filled star from an empty one in the first demo, and yellow against gray is a difference some readers cannot see. Using a filled glyph for the rating and an outlined glyph for the remainder gives a second cue that survives any color vision difference. The read only variant below leans on the same idea by exposing the number in the accessible name.

A read only rating is not a control and should not be focusable. Mark the whole thing role="img" with an aria-label that states the value, such as Rated 3.7 out of 5.

Mistakes worth avoiding

  • Hiding the radios with display: none. The rating becomes impossible to set with a keyboard, while still looking fine to a mouse user.
  • Giving the radios different name values. Each star becomes its own group, so all five can be checked at once and the fill runs the wrong way.
  • Writing the radios in ascending order while using ~. The combinator fills the stars above the one clicked instead of below it, which looks like the rating is inverted.
  • Forgetting that the checked rule keeps matching during hover. Pick five, then hover two, and stars three to five stay lit. Wrapping the checked rule in :not(:hover) on the container clears the old value while a preview is showing.
  • Leaving a gap between the labels. The container counts as hovered while the pointer sits in the gap but no label does, so the fill blinks off as the pointer crosses between stars.

Ship it

5 features in 4 browsers, 5 questions

Browser support

Feature Chrome Firefox Safari Edge
:checked43.53.212
flexbox21286.112
transition455.112
:focus-visible86415.486
:has()10512115.4105

Figures come from Can I Use, with :checked and the general sibling combinator both covered by the CSS3 selectors entry. The reversed row version runs anywhere. The :has() version needs a 2023 or newer browser, Firefox being the last to ship it. Since :has() here only changes which stars get color, a browser without it shows an unfilled but fully working radio group rather than a broken one.

What you can build with it

  • Product review forms. The radio group posts a number between 1 and 5 with the rest of the form, with no hidden field or script needed.
  • Average rating on a product card. The read only variant shows a fraction such as 4.3 without rounding it to the nearest whole star.
  • Feedback widgets after support tickets. One question, five options, and the whole control fits on a single line.
  • Difficulty or spice level indicators. Same mechanism with a different glyph. Chili peppers and flames both work, since the fill is a color change on text.
  • Survey matrix rows. Several rating rows stacked, each with its own name, which keeps the groups independent.

Frequently asked questions

Why does my star rating fill the wrong way?
The radios are almost certainly in ascending order with a plain ~ selector. That combinator only matches following siblings, so checking star 3 highlights 4 and 5. Either write the inputs from 5 down to 1 and reverse the row with flex-direction: row-reverse, or keep ascending order and select backwards with label:has(~ input:checked).
How do I show a half star or a decimal average?
Stack two rows of stars and clip the top one. The lower row is the empty color, the upper row is the fill color with overflow: hidden and a width of the rating divided by five. A 3.7 out of 5 becomes width: 74%. The second variant on this page does exactly that.
Can a CSS star rating be used in a real form?
Yes, a CSS star rating works in a real form. The inputs are ordinary radios, so give them a shared name and a value and they post like any other radio group. Add required to one of them and the browser will block submission until a star is picked.
How do I make the star rating keyboard accessible?
Stop hiding the inputs with display: none. Clip them with position: absolute; width: 1px; height: 1px; clip-path: inset(50%) so they keep their place in the tab order, then draw the focus ring on the label with input:focus-visible + label. Arrow keys then move through the group the way they do on any radio set.
Can I use an SVG icon instead of the star character?
Yes, an SVG icon works in place of the star character. Put the SVG inside the label and swap fill rather than color, or set fill="currentColor" on the path and keep changing color. The selector work does not change at all, only what the label contains.