A star rating widget in CSS

A star rating widget can be created using only HTML and CSS — JavaScript isn’t needed. Accessibility software will see the widget as a group of radio buttons, and the standard keyboard interaction is supported automatically.

The HTML

We begin with the HTML for a simple group of radio buttons:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<div class="rating">
  <input id="rating1" type="radio" name="rating" value="1">
  <label for="rating1">1</label>
  <input id="rating2" type="radio" name="rating" value="2">
  <label for="rating2">2</label>
  <input id="rating3" type="radio" name="rating" value="3">
  <label for="rating3">3</label>
  <input id="rating4" type="radio" name="rating" value="4">
  <label for="rating4">4</label>
  <input id="rating5" type="radio" name="rating" value="5">
  <label for="rating5">5</label>
</div>

Without any styling, this HTML produces:

Although this example has five possible ratings, any number of ratings can be used without the need to change the CSS styling.

Creating the stars

Each star in the rating widget can be either selected or unselected. In addition, we need to indicate the focused star if keyboard navigation is being used. We’ll use an SVG containing all three states:

You can download stars.svg (335 bytes) for use on your own site.

We begin by hiding the radio buttons and styling the labels:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
.rating {
  display: flex;
}

.rating input {
  position: absolute;
  left: -100vw;
}

.rating label {
  width: 48px;
  height: 48px;
  overflow: hidden;
  padding: 48px 0 0;
  background: url('stars.svg') no-repeat top left;
}

Line 3 switches to flex layout, and lines 6 and 7 remove the radio buttons from the layout by moving them off the screen. This allows the stars to appear in a row without unwanted space between them.

Lines 11 and 12 set the size of the stars. While we would usually use relative units to scale user interface controls based on the text size, for the stars this can lead to controls that are too small or excessively large, so we instead use a reasonable fixed size. Note that we are assuming the use of border box sizing, so these dimensions include the padding.

Lines 13 and 14 hide the text labels by using padding to push them out of the container and hiding the overflow.

Line 15 shows the part of the SVG containing a selected star in each label.

Selected and unselected states

We show stars as selected by default because the sibling combinator only allows the state of a radio button to affect the elements that follow it. There are three situations in which we need to show a star as unselected (where a pointer could be a mouse, finger, or stylus):

Each of these becomes a separate CSS selector, and the background image is positioned to show the unselected star when one of these matches:

18
19
20
21
22
.rating:not(:hover) input:indeterminate + label,
.rating:not(:hover) input:checked ~ input + label,
.rating input:hover ~ input + label {
  background-position: -48px 0;
}

Focused state

We need to indicate the focused star if keyboard navigation is being used. This is particularly important before a radio button has been checked as otherwise the visitor would not know that the rating widget has focus.

We can use the :focus-visible pseudo-class to highlight the focused star if it was focused using the keyboard:

24
25
26
.rating:not(:hover) input:focus-visible + label {
  background-position: -96px 0;
}

We check that there isn’t a pointer over the rating widget so that if the visitor switches from keyboard to pointer interaction then the previously focused star won’t be highlighted while the pointer is being used.

The finished code

Combining all of the above leads to the finished code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.rating {
  display: flex;
}

.rating input {
  position: absolute;
  left: -100vw;
}

.rating label {
  width: 48px;
  height: 48px;
  overflow: hidden;
  padding: 48px 0 0;
  background: url('stars.svg') no-repeat top left;
}

.rating:not(:hover) input:indeterminate + label,
.rating:not(:hover) input:checked ~ input + label,
.rating input:hover ~ input + label {
  background-position: -48px 0;
}

.rating:not(:hover) input:focus-visible + label {
  background-position: -96px 0;
}