You need a dependable, accessible star rating that looks sharp, supports hover, click, keyboard, and fractional display, without shipping any JavaScript or external icons. By the end of this guide you will build a fully interactive 5‑star rating with pure CSS, plus a compact read‑only variant for average scores that supports halves and tenths.
Why Creating a Star Rating System with CSS Matters
Stars live in critical UI: product cards, review modals, dashboards, and leaderboards. Many teams reach for icon fonts or SVG spritemaps to draw them. That works, but it adds network requests, asset management, and color syncing problems. A CSS‑first approach lets you define the shape, state colors, and animations with variables, and you can adapt the same code for both input and display. With modern CSS features like clip-path, custom properties, and background-clip, you can render crisp stars, fill them smoothly, and keep your rating logic accessible to assistive tech.
Prerequisites
You do not need a framework or build tooling. A plain HTML page is enough. Familiarity with CSS variables and pseudo-elements helps.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
We will build two pieces: an interactive rating (radio inputs + labels) and a read‑only display for average scores. The radio approach gives you native keyboard support and form submission. The read‑only block uses a CSS fill technique for fractional values.
<!-- HTML --><br><form class="rating-demo" action="#" method="post"><br> <fieldset class="rating"><br> <legend>Rate this product</legend><br><br> <!-- Order matters for CSS sibling selectors: 5 down to 1 --><br> <input type="radio" id="star5" name="rating" value="5" class="sr-only"><br> <label for="star5" title="5 stars"></label><br><br> <input type="radio" id="star4" name="rating" value="4" class="sr-only"><br> <label for="star4" title="4 stars"></label><br><br> <input type="radio" id="star3" name="rating" value="3" class="sr-only"><br> <label for="star3" title="3 stars"></label><br><br> <input type="radio" id="star2" name="rating" value="2" class="sr-only"><br> <label for="star2" title="2 stars"></label><br><br> <input type="radio" id="star1" name="rating" value="1" class="sr-only"><br> <label for="star1" title="1 star"></label><br> </fieldset><br><br> <button class="submit" type="submit">Submit rating</button><br></form><br><br><!-- Read‑only display for averages (supports decimals) --><br><div class="rating-display" aria-label="Average rating: 3.6 out of 5" role="img" style="--value:3.6"><br> <span aria-hidden="true"></span><br></div>The interactive version places inputs and labels inside a fieldset so the group is announced correctly. Inputs appear before their labels (5 to 1) to make sibling selectors predictable. The read‑only block accepts a CSS variable –value for any fractional rating between 0 and 5.
Step 2: The Basic CSS & Styling
Set global variables for colors, star size, and spacing. We will reuse them across both widgets. A small visually hidden utility keeps inputs keyboard accessible while removing them from sight.
/* CSS */
:root {
--star-size: 2.4rem;
--star-gap: 0.4rem;
--star-empty: #cfd8dc;
--star-fill: #ffb400;
--star-focus: #2962ff;
--text: #0f172a;
--bg: #ffffff;
--accent: #f1f5f9;
--shadow: rgba(0,0,0,.18);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
background: var(--bg);
display: grid;
place-content: center;
gap: 2.5rem;
padding: 2rem;
}
.rating-demo {
padding: 1rem 1.25rem;
border-radius: 12px;
background: var(--accent);
box-shadow: 0 6px 16px var(--shadow);
display: grid;
gap: 1rem;
width: max-content;
}
.submit {
padding: .55rem .9rem;
border: 0;
border-radius: 8px;
background: var(--star-fill);
color: #1f1300;
font-weight: 600;
cursor: pointer;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
white-space: nowrap;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
border: 0;
}Advanced Tip: Keep all star colors and sizes in variables. You can theme an entire product card grid by changing a single –star-fill value on a parent container. This also enables dark mode by swapping a few root values.
Step 3: Building the Interactive Stars
The rating group uses labels as the visible stars. Each label draws a geometric star via clip-path, which means the background color becomes the star. Hover and checked states color stars using sibling selectors. Keyboard users change the selection with arrow keys because these are native radios.
/* CSS */
.rating {
border: 0;
margin: 0;
padding: 0;
display: inline-flex;
flex-direction: row-reverse; /* helps the sibling selector logic */
align-items: center;
gap: var(--star-gap);
}
.rating > legend {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.rating > label {
inline-size: var(--star-size);
aspect-ratio: 1;
cursor: pointer;
position: relative;
color: var(--star-empty); /* currentColor drives star fill */
display: grid;
place-items: center;
}
/* Draw the star shape with clip-path so currentColor becomes the fill */
.rating > label::before {
content: "";
position: absolute;
inset: 0;
background: currentColor;
clip-path: polygon(
50% 0%,
61% 35%,
98% 35%,
68% 57%,
79% 100%,
50% 75%,
21% 100%,
32% 57%,
2% 35%,
39% 35%
);
filter: drop-shadow(0 2px 2px var(--shadow));
transition: transform .16s ease, color .16s ease;
}
/* Hover highlights the star under the cursor and all stars to its left */
.rating > label:hover,
.rating > label:hover ~ label {
color: var(--star-fill);
transform: scale(1.06);
}
/* Checked state persists the color */
.rating > input:checked ~ label {
color: var(--star-fill);
}
/* Keyboard focus ring on the radio maps to its label */
.rating > input:focus-visible + label::after {
content: "";
position: absolute;
inset: -6%;
border-radius: 12px;
outline: 3px solid var(--star-focus);
outline-offset: 2px;
}
/* Touch target comfort: slightly larger hit zone */
.rating > label { padding: 2px; }How This Works (Code Breakdown)
The fieldset wraps the group to give an accessible name via the legend, which we visually hide but keep in the accessibility tree. The inputs remain in the DOM for screen readers and keyboard control, using the sr-only technique instead of display: none so they stay focusable.
Each label is the visible star. The label sets color to –star-empty, and the ::before pseudo-element fills the entire label box with background: currentColor. The clip-path polygon cuts that square into a star. If you want to study geometric star building from scratch, read the tutorial on how to make a 5‑point star with CSS; it explains the points and ratios behind this path.
We use flex-direction: row-reverse so that when a label is hovered, the general sibling selector ~ highlights the labels that appear to its right in the DOM, which in visual order are to its left. That gives the familiar “fill from left to the hovered star” behavior without JavaScript.
Checked radios color stars by selecting input:checked ~ label. The checked input appears before the labels you want filled, so the general sibling relation works cleanly. The focus-visible style is applied to the label via + because the input precedes its label; this gives a clear ring when tabbing. Add padding to labels to enlarge the hit target without changing the star shape.
Step 4: Building the Read‑Only Average Rating
For product lists and review summaries you often need a compact, fractional display. This version layers two “★★★★★” strings and fills the top layer with a gradient based on a percentage. It renders half and tenths cleanly and stays one element in the DOM.
/* CSS */
.rating-display {
--stars: 5;
--value: 3.2; /* fallback if inline style is missing */
--percent: calc(var(--value) / var(--stars) * 100%);
--spacing: 0.25ch;
position: relative;
display: inline-block;
font-size: 2rem;
line-height: 1;
letter-spacing: var(--spacing);
}
/* Base gray stars */
.rating-display::before {
content: "★★★★★";
color: var(--star-empty);
}
/* Gold fill layer, clipped to a percentage width using background-clip:text */
.rating-display > span::before {
content: "★★★★★";
position: absolute;
inset: 0;
background: linear-gradient(90deg, var(--star-fill) var(--percent), var(--star-empty) var(--percent));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
/* Optional subtle depth */
.rating-display { text-shadow: 0 2px 0 rgba(0,0,0,.05); }How This Works (Code Breakdown)
The component computes –percent by dividing the value by the star count. The top layer uses a linear-gradient where the stop switches from gold to gray at –percent. background-clip: text clips the gradient to the glyph shapes, giving you a clean fractional fill with a single element and no masks. If you prefer a geometric approach over glyphs, you can stack two geometric star rows and crop the top row with width: calc(var(–percent)) while keeping the star shape from the interactive version. You can also create a decorative burst on the first star for a promoted rating; the shape guide on how to make a starburst with CSS pairs well with a short attention animation.
Advanced Techniques: Adding Animations & Hover Effects
A small scale “pop” and a sparkle on commit adds feedback without being noisy. The key is keeping it short and respecting motion preferences. We will animate labels on hover and add a quick twinkle when a star becomes checked.
/* CSS */
@keyframes pop {
40% { transform: scale(1.12); }
100% { transform: scale(1); }
}
@keyframes twinkle {
0% { filter: drop-shadow(0 0 0 rgba(255,255,255,0)); }
50% { filter: drop-shadow(0 0 10px rgba(255,255,255,.8)); }
100% { filter: drop-shadow(0 0 0 rgba(255,255,255,0)); }
}
/* Hover pop */
.rating > label:hover::before {
animation: pop .18s ease;
}
/* On commit (checked), twinkle once */
.rating > input:checked + label::before {
animation: twinkle .35s ease-out;
}
/* Respect users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
.rating > label::before { transition: none; animation: none; }
.rating > input:checked + label::before { animation: none; }
}pop uses a quick scale that peaks at 40% and returns to normal. twinkle momentarily boosts a light drop‑shadow around the star. The prefers-reduced-motion query removes these effects for users who opt out of animation.
Accessibility & Performance
Ratings often show up many times on a page. Make the interaction clear, keep the markup lean, and avoid heavy paint work so lists scroll smoothly on budget devices.
Accessibility
Use a fieldset with a legend to name the radio group. Keep inputs in the tab order by visually hiding them, not removing them. Labels give large click targets, which benefits touch users too. Titles on labels announce the star count on hover for mouse users. For screen readers, the radio’s accessible name comes from its label and the legend, which reads as “Rate this product, 3 of 5,” for example.
For read‑only displays, use role=”img” with an aria-label like “Average rating: 3.6 out of 5.” If the rating is purely decorative, add aria-hidden=”true” instead. The hover and commit animations are wrapped in prefers-reduced-motion to respect user settings.
Performance
This solution is CSS‑only. clip-path on simple polygons is fast on all modern browsers. Avoid stacking heavy box-shadows or large backdrop-filter effects on every star in large grids. The drop-shadow filter on one pseudo-element per star is light. Defining star size, gaps, and colors as variables reduces duplicated declarations. If you render thousands of ratings on a feed, favor the read‑only text‑fill variant, which paints quickly and avoids multiple shapes.
Take This Pattern Further
You now have an accessible, minimal star rating system: a geometric, interactive input with hover and keyboard support, and a compact display that handles fractional values elegantly. You can swap star geometry, scale sizes with a single variable, and theme colors per category. If you need other shapes for badges or playful accents around ratings, explore the geometry behind stars and polygons in the library, starting with the guide on how to make a 5‑point star with CSS.