Star Rating Input Using HTML-CSS
Tags: html, css, codenewbie, tutorial
Published: 2022-09-29 12:51:30

We are familiar with the expected behaviour of a five-star-rating input.
Blank stars are shown. The user decides how many out of five they would rate the entity in question. They select (tap/click) the star which would be at the rated number if counted from left (or let's say reading direction). The visual now shows the star and all the stars prior to it filled with some colour to indicate active state. e.g. If user wants to give a rating of 3 out of 5, they click on star number 3. Then star number 1, 2 and 3 all get lit up. Not selecting anything usually means rating was skipped and isn't considered as a zero. Once something is selected, you usually cannot go back to skipping the rating.

### Example
![Demo animation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sccim0urz6mgdt84qvox.gif)

This is quite straight-forward to achieve using JavaScript, of course, but as an exercise let's try doing it in plain CSS.

### Analysis
What do we want in the end? A choice. From a choice of five. There are in-built form elements for this purpose, let's see if we can style them for our needs.
**Option 1**: The `select` drop-down. The options are styled almost exclusively by the browser to provide best possible experience for the device the user is on. This is not suitable for our purposes.
**Option 2**: Radio buttons!
:heavy_check_mark: They let the user choose one option of a group 
:heavy_check_mark: They can each be styled differently
:heavy_check_mark: They can each have their own label tags
:heavy_check_mark: They can have different styling for each different state (i.e. whether it is selected or not)
Add to this the sibling combinators in CSS and we should be good!

### Objectives
1. Achieve the result using HTML-CSS only, without using JavaScript.
1. Keep the markup as semantic as possible.
1. Keep it keyboard and assistive technology friendly.

### Research
#### labels
These are special form elements, that can be assigned to inputs using their `for` attribute. It should match the `id` attribute of the input it needs to be assigned to. Each input can have multiple labels. Clicking on the label activates the input it is assigned to. In case of checkbox inputs, clicking on the label also changes the on/off state of the input. In short, clicking on the label is like clicking on the input itself. Even the click event on the input is fired (mostly useful for JS manipulation). So we can hide away the actual radio buttons and use their respective labels in their place.
How to do that? And will they reflect their states like focused and checked?

#### Sibling combinators `+` and `~`
These are used in CSS with the syntax
`selector1 ~ selector2`
or
`selector1 + selector2`.

Siblings are those elements which have the same parent element.

`+` selects an element matching `selector2` that is *immediately* next to `selector1`. 
e.g. `p + div` will select any `div` tag that comes immediately after a `p` tag.

`~` selects all the elements matching `selector2` that come after and are siblings of elements matching `selector1`.
e.g. `a ~ div` will select all `div` tags that come after an `a` tag and are siblings of the `a` tag.

[Here](https://www.w3schools.com/css/css_combinators.asp) is a good explanation on w3schools.

#### Pseudo-classes `:checked` `:open` etc
These are special selector modifiers that select an element in that state, so that the element in that state can be styled differently than its usual state. There are also `:first-child`, `:nth-child()` etc that select based on what order the element is in inside its parent.

### Approach 
Let's keep all the radio buttons and labels in the same wrapper element, so that they are all siblings. Let's place each label after its assigned input in the markup. This will allow us to style the label based on the state of its assigned radio button. The labels will each contain the star image that can be changed through css to indicate active and inactive states. e.g. Simple `div` elements with star-shaped `clip-path`, changed by changing the `background-color` OR `svg` elements where the star-shape is filled with `currentColor` and can be affected by changing the color attribute of the parent.

That means we are attaching labels to the inputs once semantically, using the `for` attribute, and then again in the markup order such that the state of the radio button will affect the styling of the label using the `+` combinator.
Note that both these pairings can be mutually exclusive. That means, if we get the order wrong, we might end up making the state of an input affect the style of a label that wasn't assigned to it. There are no 'label:assigned-to-a-radio-button-that-is-focused/checked' selectors. This has to be achieved through markup and an understanding of the CSS combinator selectors.

Here's what we are going to do:
```html
<div class="star-wrap">
 <input type="radio" id="st-1" value="1" name="star-radio"/>
 <label for="st-1">...</label>
 <input type="radio" id="st-2" value="2" name="star-radio"/>
 <label for="st-2">...</label>
 .
 .
 <input type="radio" id="st-5" value="5" name="star-radio"/>
 <label for="st-5">...</label>
</div>
```
Now we have set up the DOM tree such that the state of each radio button can affect its own label using the `+` modifier.

We will hide away the actual radio buttons using:
```css
input[type=radio] {
 position:fixed;
 opacity:0;
 left:-90000px;
}
```

We are not simply using `display: none` because that would make the radio button non-focusable by keyboard in some browsers and undetectable by screen-readers in most cases. Also, we are stowing it away off of left boundary, because doing it off of right or bottom would have made the document size that big and would have added scrollbars to the document to reveal the stowed away control.
Here's how it looks when the markup is ready:
![Markup is ready](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5mwut8qx2pvpkakgihgd.jpg)

I have used [Clippy](https://bennettfeely.com/clippy/) to get the star shapes using `clip-path`.
I have also used JavaScript to show the value of the star that is selected. It is not implementing any other logic.

Even though we have hidden them visually, the radio buttons are still right there in the DOM, hence we can focus/check them with keyboard and pointer(mouse/touch) through the labels. We can use `:focus` pseudo-class along with `+` combinator to select the label which is immediately after the focused radio input:
```css
input[type=radio]:focus + label {
 outline: 2px dotted black;
}
```
This will make the focused state of the radio button affect its label like so:
![Radio-button focus state shown on label](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wvwir50a3dz1ua0nubtt.jpg)

And now, for they key part, let's use the `~` modifier to colour the labels gold.
```css
input[type=radio]:checked ~ label {
 background-color: gold;
}
```

#### But basic problem...
Have you figured out the problem with this? This will make the stars *after* the checked star golden, but we want the stars *before* the checked star to be golden. Now, if we are designing for users with right-to-left scripted languages (like Urdu) and we know that they expect the stars to the right of the selected star should be lit, we'd be done. But what about users of left-to-right scripted languages (like English)?

#### Possible solutions:
**Solution 1.** Let's make the wrapper `div` into a `flex` and set its `flex-direction` as `row-reverse`.
```css
div.star-wrap {
 display: flex;
 flex-direction: row-reverse;
}
```
We would also have to edit the values of the inputs such that they are in the correct order *after* reversing.
```html
<div class="star-wrap">
 <input type="radio" id="st-1" value="5" name="star-radio"/>
 <label for="st-1">...</label>
 <input type="radio" id="st-2" value="4" name="star-radio"/>
 <label for="st-2">...</label>
 .
 .
 <input type="radio" id="st-5" value="1" name="star-radio"/>
 <label for="st-5">...</label>
</div>
```
**Result:** It works fine, but when user tries to focus with keyboard, the left-right arrow keys follow the DOM direction for the radio buttons.
The browser considers the whole group of radio buttons (grouped by the `name` attribute) as one input. We can tab to it, but once inside, we have to use arrow keys to navigate amongst the choices. Here the browser did not consider that the flex has arranged them reversed, and did not adjust the arrow key inputs accordingly.
Any other way to achieve this?

**Solution 2:** Let's make the wrapper into a regular div with text-direction reversed (right-to-left). Then let's make the labels into inline elements.
```css
div.star-wrap {
 display: block;
 direction: rtl;
}
label {
 display:inline-flex;
}
```
**Result:** This fixes the keyboard navigation problem. But when used with a screen reader, the announced order is confusing for the user. 
I tried, but no amount of `aria-` properties could fix that.
Now I am not an expert on aria (Accessible Rich Internet Applications), but the advice most often given is this:
> Don't use aria properties if there is some semantically correct solution.

Let's strive for that.

Let's first revert the order of the input `value` attributes.
```html
<div class="star-wrap">
 <input type="radio" id="st-1" value="1" name="star-radio"/>
 <label for="st-1">...</label>
 <input type="radio" id="st-2" value="2" name="star-radio"/>
 <label for="st-2">...</label>
 .
 .
 <input type="radio" id="st-5" value="5" name="star-radio"/>
 <label for="st-5">...</label>
</div>
```
This by default is good with keyboard navigation *and* aria. Now let's see if we can visually style this to our purposes.
How do we do that?
Radio button's state can only affect the labels that come *after* it.
Here's a thought. Let's colour all the stars gold first. Then let's de-colour all the stars that are after the checked radio button.
But that would include the label that is immediately after the radio button as well. That is its assigned label in our markup.
Should we change the order? label first and input after?

![Result of changing the order](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hxjaq7ry2o0d3mdzktmk.jpg)

It works! But-- Oh great. Now the focus indication is wrong.
Hmm. For the focus indication to be right, the assigned label *must* come after its radio button. Let's revert the order.

#### What if we combine the combinators?
Remember the combination selectors `selector1 ~ selector2` we talked about earlier? Here the `selector1` can itself be a combination. If we do it right, we can select *the labels that come after the label that comes after the checked radio-button*!
```css
label {
 background-color: gold;
}
input[type=radio]:checked + label ~ label {
 background-color: lightgray;
}
```
In English, this would be like saying *"Colour all the labels gold. Now. See any checked radio button? Yes? Good. See the the label next to it? Good. Now select all its sibling labels that come after it - and colour them light-gray."*.

Now it works very well!
![Reverted order with complex combinator works well](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f78eqc223uv36hqcslqj.jpg)

Let's refresh. Perf-- wait WHAT!
![All stars are gold when no input is chosen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lai137lvmps52z9g3uc7.jpg)

Right. When no radio button is chosen, no label is de-coloured. All the labels are gold by default!
Well what now?
Is the `:not()` modifier of any use? No, it will just select the labels next to unchecked radio buttons and there would be no point to arranging them so smartly all this while.
Should we just give up and use JavaScript to implement the logic?
... :thinking: ...
All the labels next to the label next to a checked radio button get de-coloured... BINGO! What if we add a hidden radio-button and label pair before all these? We will let it be checked by default. But that would mean it needs to carry some value. Does it? We can disable it. Or better yet. We can assign it a value that the back-end can interpret as 'skipped'. Like -1?
```html
<div class="star-wrap">
 <input checked type="radio" value="-1" name="star-radio"/>
 <label>...</label>
 <input type="radio" id="st-1" value="1" name="star-radio"/>
 <label for="st-1">...</label>
 <input type="radio" id="st-2" value="2" name="star-radio"/>
 <label for="st-2">...</label>
.
.
.
```
Or we can just add a `disabled` attribute to it so that it doesn't get submitted when the form is submitted and hence will be interpreted as skipped.
You know that? If we could just convince the back-end team to interpret -1 as skipped, we can even add a button to skip after the user has chosen some rating!
How? Just add another label - assigned to the skip-value radio button - before the end of same wrapper element, and style it such that it gets hidden only when the skip-value radio button is selected.

And we are done!
Semantically correct star rating input using just HTML and CSS!
Check out the CodePen for the full code.
{% codepen https://codepen.io/vaibhavbshete/pen/LYmQGEr %}