Skip to content

CSS Star Rating: Build One With :has() Instead of Reversed HTML

Key Takeaways

  • :has() elimina la necesidad de escribir las cinco estrellas al revés y de usar flex-direction: row-reverse, porque desde diciembre de 2023 permite seleccionar un elemento anterior con respecto a un elemento de referencia.
  • La versión interactiva mantiene el orden fuente 1 a 5 con , , cinco radios y etiquetas, y usa input:checked + label junto con label:has(~ input:checked) para pintar de oro la estrella elegida y todas las anteriores.
  • El patrón de hover añade una vista previa con @media (hover: hover), apaga el valor guardado mientras el puntero está dentro del grupo y evita un estado hover pegajoso en dispositivos táctiles.
  • La versión de solo lectura usa para que un lector de pantalla anuncie una sola frase, no cinco caracteres de estrella separados.
  • Las estrellas fraccionarias se dibujan con una sola propiedad --pct y background-clip: text, donde 4.3 sobre 5 se convierte en 86%, sin imágenes de media estrella ni marcado extra.

Almost every CSS star rating tutorial still tells you to write your five stars backwards. Star five first, star one last, then flex-direction: row-reverse to flip them on screen. It works, and it has been the standard answer for about a decade.

The reason for that hack disappeared in December 2023. You no longer need it, and the version without it is easier to read and much easier to keep accessible.

 

Table Of Contents

Two Different Things Get Called A Star Rating

Before any CSS, decide which one you are building, because they have almost nothing in common.

  • A read-only rating displays a score you already have. It is output. Nobody clicks it. It needs a text equivalent so the score is not carried by colour alone.
  • An interactive rating collects a score. It is a form control. It needs real inputs, keyboard support, and a name that gets submitted.

Building the second one out of <span> elements and click handlers is where most star rating widgets go wrong.

Two panels. The top shows a read-only four of five star rating marked up with role img and an aria-label. The bottom shows an interactive three of five rating built from a fieldset containing five radio inputs.
A rating you display and a rating you collect are different components with different requirements.

Why The Old Technique Reverses Your HTML

When you check star three, you want stars one, two and three to light up. In natural document order those stars come before the checked input, and for most of CSS history there was no way to select an earlier sibling. The sibling combinators only ever looked forward.

So people flipped the problem. Put star five first in the markup, and the stars you want to highlight become the ones that come after the checked input, which ~ can reach. Then reverse the visual order back with flexbox.

/* the old way: markup runs 5,4,3,2,1 */
.rating { display: flex; flex-direction: row-reverse; }
.rating input:checked ~ label { color: #f5a524; }

The cost is real. Your DOM order no longer matches your visual order, which means keyboard and screen reader users move through the stars from five down to one. It also makes the markup confusing to anyone who edits it later.

The Modern Version With :has()

The :has() pseudo-class removed the constraint. MDN describes it as “a way of selecting a parent element or a previous sibling element with respect to a reference element.” That second half is exactly what a star rating needs.

Write the stars in the order they appear, one through five, then use two selectors: the label directly after the checked input, and every label that has a checked input somewhere after it.

.rating {
  display: inline-flex;
  gap: 4px;
  font-size: 34px;
  line-height: 1;
}

.rating input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
}

.rating label { color: #d4d4dc; cursor: pointer; }

/* the checked star, plus every star before it */
.rating input:checked + label,
.rating label:has(~ input:checked) { color: #f5a524; }

The markup stays in the order a person reads it:

<fieldset class="rating">
  <legend class="sr-only">Rate this product</legend>
  <input type="radio" name="rating" id="r1" value="1">
  <label for="r1"><span class="sr-only">1 star</span>★</label>
  <input type="radio" name="rating" id="r2" value="2">
  <label for="r2"><span class="sr-only">2 stars</span>★</label>
  <input type="radio" name="rating" id="r3" value="3">
  <label for="r3"><span class="sr-only">3 stars</span>★</label>
  <input type="radio" name="rating" id="r4" value="4">
  <label for="r4"><span class="sr-only">4 stars</span>★</label>
  <input type="radio" name="rating" id="r5" value="5">
  <label for="r5"><span class="sr-only">5 stars</span>★</label>
</fieldset>
Four rows of five stars showing the states produced by the has selector: star 1 checked lights one star, star 3 checked lights three, star 5 checked lights all five, and nothing checked leaves all five grey.
Source order runs 1 to 5. The highlight comes from input:checked plus label and label:has(~ input:checked).

Rendering that in Chrome and sampling the star colours confirms it behaves: with star three checked the first three render gold and the last two grey, with star five checked all five are gold, with star one checked only the first is, and with nothing checked all five stay grey.

Adding The Hover Preview

The same logic covers hover, since you want the star under the cursor and everything before it to light up.

@media (hover: hover) {
  .rating:hover input:checked + label,
  .rating:hover label:has(~ input:checked) { color: #d4d4dc; }

  .rating label:hover,
  .rating label:has(~ label:hover) { color: #f5a524; }
}

The first rule mutes the stored value while the pointer is inside the group, so the preview does not fight with the current selection. Wrapping the whole thing in @media (hover: hover) keeps touch devices out of it, where a sticky hover state would leave stars highlighted after a tap.

The Read-Only Version For Showing A Score

For display you do not need inputs at all. What you do need is for the score to survive when the stars do not, whether that is a screen reader, a text browser, or a stylesheet that failed to load.

<div class="stars" role="img" aria-label="Rated 4 out of 5">
  <span class="on">★</span><span class="on">★</span>
  <span class="on">★</span><span class="on">★</span>
  <span>★</span>
</div>

The role="img" with an aria-label makes the whole group announce as one thing, “Rated 4 out of 5,” rather than reading out five separate star characters. If you also print the number in visible text nearby, drop the label and mark the stars aria-hidden="true" instead, so the score is not announced twice.

Half Stars Without Extra Markup

Average ratings rarely land on a whole number. Rather than adding half-star images, paint a gradient across the row and clip it to the star shapes with background-clip: text. One custom property then drives the fill.

.stars {
  --pct: 86%;             /* 4.3 out of 5 */
  display: inline-block;
  background-image: linear-gradient(90deg, #f5a524 var(--pct), #d4d4dc var(--pct));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  font-size: 34px;
}
Four star rows showing fractional fills: 2. 5 out of 5 at fifty percent, 3. 5 at seventy percent, 4. 3 at eighty-six percent and 4. 7 at ninety-four percent, each partially filling a star.
One custom property drives any fraction, with no half-star images and no extra markup.

Set --pct to the score divided by five. A 4.3 becomes 86 percent, and the fourth star fills roughly a third of the way across. This gives you any fraction, not just halves, from a single number.

One trap here cost me a rebuild of the demo above, and it is easy to miss. Use background-image, not the background shorthand, when the gradient lives in a separate modifier class. The shorthand resets every background longhand it does not mention, including background-clip, so a later rule like .s86 { background: linear-gradient(...) } silently puts the clip back to border-box. The result is a solid gradient rectangle where your stars should be, with no error anywhere to tell you why.

Be aware too that letter-spacing adds gaps that the percentage does not know about, so the fill drifts slightly from the true fraction at high scores. If you need the fraction to be exact, drop the letter spacing and control the gap with the font size instead.

The Accessibility Details That Actually Matter

  • Do not use display: none on the inputs. That removes them from the tab order entirely. Position them off-screen at 1px instead, as in the CSS above, so they stay focusable.
  • Give every label real text. A visually hidden “3 stars” inside each label is what a screen reader announces. A bare star character is not a label.
  • Use a fieldset and legend. Radios in a group need a group name, and this is the markup that provides one without any ARIA.
  • Add a visible focus ring. Because the input is off-screen, style the focus on the label: .rating input:focus-visible + label { outline: 2px solid; outline-offset: 2px; }
  • Do not rely on colour alone. Gold against grey is the only difference between a filled and empty star, so the accessible name has to carry the score.

A radio group also gives you arrow-key navigation for free. Tab moves into the group once, then arrow keys move between the five options, which is the behaviour people expect.

Browser Support

MDN lists :has() as Baseline “Widely available,” noting it has “been available across browsers since December 2023.” That is the only modern piece here. One limitation worth knowing: “the :has() pseudo-class cannot be nested within another :has(),” which does not affect this pattern but will bite you on more complex selectors.

If you must support browsers older than that, keep the reversed-markup version as a fallback inside an @supports not (selector(:has(*))) block rather than shipping the old technique to everyone.

Doing This In Elementor

Elementor has no star rating input of its own, so a review form built in the editor usually ends up as a dropdown of numbers, which works but reads as a survey rather than a rating.

The Plus Addons for Elementor covers both halves of this. Its Social Reviews widget pulls in existing star ratings from Google and other sources for the read-only case, and its Testimonial widget renders per-item star scores, so you are not hand-building display markup for every entry. For collecting a rating, its form widget plus the CSS above in a Custom CSS field gets you a real radio group rather than a select box.

If you are comparing options for displaying reviews first, the testimonial plugin roundup and the product review plugin comparison both cover which ones output proper rating markup.

Frequently Asked Questions

Do I Still Need row-reverse Anywhere?

No. That pattern existed only to work around the missing previous-sibling selector. With :has() you can keep source order and visual order the same, which is better for keyboard and screen reader users.

Should I Use SVG Icons Instead Of The Star Character?

For anything branded, yes. The star character renders differently across platforms and its size depends on the font. An inline SVG gives you a consistent shape, and fill: currentColor means the same CSS above still drives it.

How Do I Add Structured Data For Review Stars?

The visual stars and the structured data are separate jobs. You need AggregateRating or Review schema with the numeric value for search engines to understand the score. Styling stars in CSS does nothing for rich results on its own.

Why Does My Rating Reset When The Form Submits?

Every radio in the group needs the same name and a distinct value. If the names differ they behave as five separate one-option groups, so the browser sends all of them and none of them are mutually exclusive.

Can I Make It Read-Only Without Disabling It?

Use the display version rather than disabling inputs. A disabled control is still announced as a form field, which tells a screen reader user there is something to interact with when there is not.

Suggested Reading

Related Frequently Asked Questions