DEV Community

Cover image for Star Rating Input Using HTML-CSS
vaibhavbshete
vaibhavbshete

Posted on

Star Rating Input Using HTML-CSS

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

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!
✔️ They let the user choose one option of a group
✔️ They can each be styled differently
✔️ They can each have their own label tags
✔️ 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.
  2. Keep the markup as semantic as possible.
  3. 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 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:

<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>
Enter fullscreen mode Exit fullscreen mode

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:

input[type=radio] {
 position:fixed;
 opacity:0;
 left:-90000px;
}
Enter fullscreen mode Exit fullscreen mode

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

I have used 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:

input[type=radio]:focus + label {
 outline: 2px dotted black;
}
Enter fullscreen mode Exit fullscreen mode

This will make the focused state of the radio button affect its label like so:
Radio-button focus state shown on label

And now, for they key part, let's use the ~ modifier to colour the labels gold.

input[type=radio]:checked ~ label {
 background-color: gold;
}
Enter fullscreen mode Exit fullscreen mode

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.

div.star-wrap {
 display: flex;
 flex-direction: row-reverse;
}
Enter fullscreen mode Exit fullscreen mode

We would also have to edit the values of the inputs such that they are in the correct order after reversing.

<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>
Enter fullscreen mode Exit fullscreen mode

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.

div.star-wrap {
 display: block;
 direction: rtl;
}
label {
 display:inline-flex;
}
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

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

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!

label {
 background-color: gold;
}
input[type=radio]:checked + label ~ label {
 background-color: lightgray;
}
Enter fullscreen mode Exit fullscreen mode

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

Let's refresh. Perf-- wait WHAT!
All stars are gold when no input is chosen

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?
... 🤔 ...
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?

<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>
.
.
.
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
fruntend profile image
fruntend

Сongratulations 🥳! Your article hit the top posts for the week - dev.to/fruntend/top-10-posts-for-f...
Keep it up 🫰