CSS Star Rating

Clickable star rating using radio inputs and the general sibling combinator.

Published May 19, 2026 Intermediate 8 min read

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.

Click a star to rate

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;
}

Other ways to build it

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 the group announces as 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;
}

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.

CSS properties used

~ (general sibling)
Matches every following sibling, not just the next one. It is what fills a run of stars from one checked input.
:checked
Matches the selected radio. Combined with the sibling combinator it turns a single selection into a range of highlighted labels.
flex-direction
row-reverse paints the source order right to left, which puts the filled run on the left while the selectors still run forward through the DOM.
:hover
Drives the preview fill. Applied to the label and to its following siblings so the whole run lights up together.
transition
A short color transition on the label softens the fill as the pointer moves across the row.
:has()
Selects a label based on what comes after it, which removes the need to reverse the markup at all.

Browser support

FeatureChromeFirefoxSafariEdge
: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.

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 and a screen reader finds nothing to announce. 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. A screen reader reads ★ as black star, so a five star group announces itself as five identical black stars with no indication of which is which. 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 then announces as 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. Without that, a screen reader either reads five star characters in a row or skips the element entirely, and neither tells the reader the score.

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. Chilli peppers and flames both work, since the fill is just a color change on text.
  • Survey matrix rows. Several rating rows stacked, each with its own name, which keeps the groups independent.

Mistakes worth avoiding

  • Hiding the radios with display: none. The rating becomes impossible to set with a keyboard and invisible to screen readers, 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.

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