Source Code

<div class="demo">
  <form class="rating-form">
    <p class="rating-label">Rate your experience</p>
    <fieldset class="star-rating">
      <legend class="sr-only">Star rating</legend>
      <input type="radio" name="rating" id="star5" value="5" class="star-input" />
      <label for="star5" class="star" aria-label="5 stars">★</label>
      <input type="radio" name="rating" id="star4" value="4" class="star-input" />
      <label for="star4" class="star" aria-label="4 stars">★</label>
      <input type="radio" name="rating" id="star3" value="3" class="star-input" checked />
      <label for="star3" class="star" aria-label="3 stars">★</label>
      <input type="radio" name="rating" id="star2" value="2" class="star-input" />
      <label for="star2" class="star" aria-label="2 stars">★</label>
      <input type="radio" name="rating" id="star1" value="1" class="star-input" />
      <label for="star1" class="star" aria-label="1 star">★</label>
    </fieldset>
  </form>
</div>

CSS Only Star Rating — Radio Input Hack, No JavaScript Required

Star Rating — CSS Only Radio Hack (No JavaScript) · Forms · Plain HTML & CSS · Live preview

What's included

Features

Reverse-order (5,4,3,2,1) radio markup combined with flex-direction: row-reverse to enable forward-only ~ selectors
Hover preview lights the hovered star and all stars before it via .star:hover ~ .star, without touching the committed value
:not(:hover) guard cleanly separates the temporary hover preview from the actual :checked selection
Real, submittable radio group — reading the rating server-side needs no JavaScript collection step
Explicit aria-label="N stars" per star since the ★ glyph alone carries no semantic meaning
Fieldset + visually-hidden legend (clip-based, not display:none) for correct screen-reader group announcement
:focus-visible ring on the hidden input styled onto its label sibling for full keyboard accessibility
Zero JavaScript — works in sanitized comment widgets, CMS testimonial blocks, and script-stripped embeds

About this UI Snippet

CSS-Only Star Rating Widget — Reverse-Order Radios and the General Sibling Combinator

Screenshot of the Star Rating — CSS Only Radio Hack (No JavaScript) snippet rendered live

This is one of the oldest and most elegant CSS-only tricks, and it hinges entirely on a detail that's easy to miss the first time: CSS has no "select the elements *before* this one" selector, but it does have ~, which selects elements *after*. The star rating gets around the missing capability by reversing the DOM order of the stars and using flex-direction: row-reverse to put them back on screen in the expected 1-2-3-4-5 visual order.

Why the markup is written 5,4,3,2,1

To light up "this star and every star to its left" using only forward-looking sibling selectors, "to its left" has to *also mean* "later in the DOM." Writing the stars in the order 5,4,3,2,1 achieves exactly that: visually, star 1 is leftmost and star 5 is rightmost, but in the DOM, star 5 comes first and star 1 comes last. So for star 3, the stars visually to its left (2 and 1) are also its *later DOM siblings* — meaning .star3 ~ .star correctly reaches them.

Un-reversing the visual order with row-reverse

.star-rating { display: flex; flex-direction: row-reverse } flips the *rendering* order without touching the DOM order, so the browser paints star 5 on the right and star 1 on the left — the natural reading order — while the underlying sibling relationships used by the CSS selectors remain based on the reversed DOM order.

The hover-preview rule

.star-rating:hover .star:hover ~ .star reads as: "while the mouse is anywhere over the rating group, take the specific star currently under the cursor, and light it up along with every star that is its later DOM sibling." Combined with the reversed markup, "later DOM sibling of the hovered star" is precisely "every star visually to its left" — exactly the preview behavior a star rating needs. The hovered star's own color comes from the plain .star:hover half of the same selector.

The committed-selection rule, and the priority conflict it resolves

.star-rating:not(:hover) .star-input:checked + .star ~ .star applies the identical logic to whichever star is :checked, but only while the group is *not* being hovered — the :not(:hover) guard is what lets the mouse temporarily "preview" a different rating without visually disturbing the committed one, and then cleanly restores the real selection the instant the cursor leaves. Without that guard, hovering near — but not selecting — a lower rating would look identical to actually having selected it, which would be misleading.

Accessibility: five radios, one legend

Each star's <label> carries an explicit aria-label="N stars" since the visible character is just a "★" glyph, which alone tells a screen reader nothing about what it does. The group is wrapped in a <fieldset> with a visually-hidden <legend> (clipped via the standard .sr-only pattern, not display:none, so it stays in the accessibility tree) — this is how a screen reader announces "Star rating, 5 items" context before reading each radio's own label, matching how sighted users perceive the group as one cohesive control.

Where this is the only working option

Star ratings are frequently required inside review-submission widgets embedded in third-party pages, sanitized comment sections, and CMS testimonial blocks — all places that commonly strip <script> tags from user- or editor-authored HTML for security. Because every part of this widget's interactivity — the hover preview and the committed selection — is native radio-input and :hover behavior read through sibling selectors, it remains a fully working, real form field in exactly those script-stripped contexts.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Ask an AI assistant to explain step by step why the stars must be written in reverse DOM order for the ~ combinator to work, and to trace through exactly which elements .star:hover ~ .star selects for, say, the third star from the left — that mental trace is the key to understanding (and debugging) this whole pattern. It's also a good prompt for extending this to half-star precision, or for adding a read-only "display only" variant that shows a pre-set rating without any of the interactive hover/checked styling.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a 5-star rating input widget using only HTML and CSS — no JavaScript, no onclick attributes, no <script> tags — that remains a real, submittable radio input group.

Requirements:
- Use five <input type="radio"> elements sharing one name attribute and distinct numeric value attributes, each immediately followed by a <label> styled as a star glyph.
- Write the radio/label pairs in reverse order in the HTML (highest value first) and use CSS flex-direction: row-reverse to restore the correct 1-through-5 left-to-right visual order — do not use JavaScript or SVG scripting to achieve the ordering.
- Implement a hover preview: hovering any star must visually highlight that star and every star to its left, using only the ~ general sibling combinator, without altering the actually selected (checked) rating.
- Implement the committed-selection highlight using the same sibling-combinator approach keyed off :checked instead of :hover, and ensure it is not visually overridden except during an active hover.
- Give each star label an appropriate aria-label describing its numeric value, and wrap the group in a fieldset with a visually-hidden (not display:none) legend describing the control's purpose.
- One star should be pre-selected via the checked attribute by default.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Write stars in reverse DOM orderMarkup order must be 5,4,3,2,1 (highest first) — this is what lets the ~ combinator reach "every star before this one" visually.
  2. 2
    Apply flex-direction: row-reverseThis flips only the rendering order, putting star 1 on the left and star 5 on the right, without changing the DOM order the selectors rely on.
  3. 3
    Keep each input immediately before its labelThe .star-input:checked + .star rule needs the label to directly follow its radio, with no elements between them.
  4. 4
    Wrap the group in a fieldset with a hidden legendUse a visually-hidden <legend> (clipped, not display:none) so screen readers announce the group's purpose before each star's own aria-label.
  5. 5
    Read the value on submitBecause these are real radios sharing name="rating", a normal form submission or FormData read returns the selected numeric value directly — no JS collection needed.

Real-world uses

Common Use Cases

SHOP
Product Review Forms
Collecting a star rating alongside a review text field in an e-commerce product page, submitted with the rest of the form
Post-Support Satisfaction Surveys
A lightweight CSAT-style rating embedded at the end of a support ticket or chat transcript
CMS Testimonial Submission Widgets
User-submitted testimonial forms rendered inside CMS blocks that strip embedded scripts for security
Feedback Links in HTML Email
A visual rating control in a marketing or transactional email footer, where scripts never execute
Learning the Reverse-Order Sibling Trick
The canonical example for understanding how ~ can simulate "select everything before me" via DOM reversal
Related: Dual Range Price Filter
See the Dual Range Price Filter for a related forms pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

That works too, but requires JavaScript to run at all. This CSS-only version works identically inside sanitized HTML, CMS content blocks, and email clients where scripts are stripped or blocked outright, which a JS-based version cannot.

CSS only has a general sibling combinator (~) that selects elements after a given one in the DOM, with no equivalent for "before." Reversing the DOM order and then using flex-direction: row-reverse to restore the correct visual order lets the ~ combinator reach every star to the left, even though CSS itself cannot select backwards.

Yes, but it requires more granular markup — typically doubling to 10 radio inputs (half-star increments) with each label sized to half a star's width, or using a background-clip gradient trick tied to :checked. It significantly increases markup complexity.

Because all five inputs share name="rating" with distinct value attributes (5 down to 1), a standard form POST or FormData read on the client returns the checked one's value exactly like any other radio group — no special handling needed.

No — a screen reader would just announce "star" or the raw glyph with no indication of position. Each label here has an explicit aria-label="N stars" precisely to supply that missing context, and the group has a visually-hidden legend for the same reason.