Radio-based Star Rating Input

How to create a custom-styled star rating input system

A 5-star rating system is a very common convention, but building one from scratch can be a challenge. This writeup covers my journey recently making one and some of the tricks learned along the way.

Final Solution

What's happening here?

We're taking native <input type="radio"> elements and using CSS and SVGs to to style them to look like stars.

This is a fairly intense solution - You don't need SVGs, and much less CSS to pull off something similar.

Okay, but why?

Fair! You could totally just use standard HTML radio inputs. That's arguably the most straight-forward way to do this...

This solution is nice because it...

  1. leverages standard HTML which makes it clear and stable
  2. works without Javascript or CSS
  3. is accessible

... but it's missing a few things I think are important...

  1. Actual star shapes to meet convention/expectation
  2. Custom look and feel to match my brand
  3. Flexibility for different sizes and positioning

First, let's touch on the actual star shapes...

Star Icons

I made the star icons inside Figma pretty easily using the star shape tool.

I ended up with this SVG markup (class added by me manually):

<svg class="star__icon" height="22" viewBox="0 0 22 22" width="22" xmlns="http://www.w3.org/2000/svg">
<path stroke-width="1.5" d="M8.33758 7.32105L11 1.74152L13.6624 7.32105C13.8446 7.70294 14.2077 7.96671 14.6272 8.02201L20.7564 8.82995L16.2727 13.0862C15.9658 13.3776 15.8271 13.8043 15.9042 14.2204L17.0298 20.2993L11.5963 17.3503C11.2244 17.1484 10.7756 17.1484 10.4037 17.3503L4.97022 20.2993L6.09584 14.2204C6.17288 13.8043 6.03421 13.3776 5.72733 13.0862L1.2436 8.82995L7.37279 8.02201C7.7923 7.96671 8.15535 7.70294 8.33758 7.32105Z"></path>
</svg>
svg

Building the Foundation

Here's what we need:

  1. Native <input type="radio"> and and <label> elements (helps ensure accessibility).
  2. A default/empty state (no stars selected by default)
  3. A checked state. If I click star 3, stars 1, 2 & 3 should highlight.
  4. A hover state. When I hover the stars, they should indicate that they are clickable. This hover state should not conflict with the checked states in bad ways.

So let's scaffold out the markup. In short, we need 6 inputs and 6 associated labels. The labels will eventally be the targets we style but more on that next.

_Note: We need 6 so we can add a 0 star. This 0 helps us with some selector juggling but it would also help with form submission data validation depending on your setup.

<input type="radio" id="radio-0" name="star-rating" />
<label for="radio-0">1 star</label>
<input type="radio" id="radio-1" name="star-rating" />
<label for="radio-1">1 star</label>
<input type="radio" id="radio-2" name="star-rating" />
<label for="radio-2">2 stars</label>
<input type="radio" id="radio-3" name="star-rating" />
<label for="radio-3">3 stars</label>
<input type="radio" id="radio-4" name="star-rating" />
<label for="radio-4">4 stars</label>
<input type="radio" id="radio-5" name="star-rating" />
<label for="radio-5">5 stars</label>
html

That gives us this totally functional and totally not fancy rating system... Here's that preview again:

Adding Some Style

Adding a wrapper div will soon help us with aligning the stars. Let's also start adding class names we can target.

<div class="star-container">
<input class="star__input" ... />
<label class="star__label" ...>
<!-- ... rinse and repeat 5 more times -->
</div>
html

We could pick better names here, or use BEM (or similar) to make things scope a bit tighter, but this workes for now. Adapt for your own use!

Writing Some CSS

I'm using SCSS here but intentionally not nesting selectors (plus, nesting can be dangerous!). Regardless of the dangers, not nesting in our case is hugely beneficial because it makes it SO MUCH EASIER to see what's happenening when we look at our SCSS.

First up, hide the native radio input. I do this by the common "visually hidden" method that is often used for site accessibility.

.star__input {
position: absolute;
display: inline-block;
height: 1px;
width: 1px;
margin: -1px;
clip: rect(0, 0, 0, 0);
overflow: hidden;
}
scss

Row Layout

Let's use Flexbox to easily get the stars to appear in a row. Now that we have a wrapper div, let's use it.

.star-container {
display: flex;
align-items: center;
}
scss

Getting Our Stars Added

Okay, there are a few ways to do this, and I'm sure the way I did is wrong for reasons... But, it's also right for reasons.

This method inlines SVGs in the DOM, but you could also use an image tag (<img src="/path/to/icon.svg" />), a background image, or an icon font.

At the end of the day, the most important part of this is your DOM order because it allows us to do this:

.star__input:checked + .star__label {
// do stuff
}
scss

We can now leverage HTML's native checked state and style the next sibling (via the + selector) accordingly.

Inlining has certain advantages - namely being able to manipulate svg path data via CSS, here's what I ended up with:

<input class="star__input" type="radio" ... />
<label class="star__label" ...>
<svg class="star__icon">...</svg>
</label>
<!--- rest of stars fallow same pattern --->
html

Final Style Passes

Here's the final scss solution with comments added that explain what's happening.

We couldn't do this without the adjacent sibling and general sibling selectors.

:root {
--star-unchecked: #ffffff;
--star-checked: #ffb300;
--star-hover: #f9b924;
--star-stroke: #ffb300;
--outline-color: #6864e8;
}
// copypasta'd "visually hidden" css that is commonly used
// this hides things from people who can see the star graphics,
// but allows them to be seen by assistive technologies.
@mixin visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: auto;
margin: 0;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
white-space: nowrap;
pointer-events: none;
}
html {
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin: 0;
}
.star-container {
display: flex;
align-items: center;
}
// the 0th star should always be hidden
#radio-0 {
display: none !important;
}
.star {
// space the stars out evenly
& + .star {
padding-left: 2px; // needs to be padding not margin
}
}
.star__input {
@include visually-hidden;
}
.star__label {
display: block;
color: var(--star-checked); // Sets the default star bg color
cursor: pointer;
transition: color 0.2s ease;
}
.star__text {
@include visually-hidden;
}
.star__icon {
display: block;
width: 40px;
height: auto;
pointer-events: none;
path {
fill: currentColor;
stroke: var(--star-stroke);
}
}
// Find the currently checked input, then, from it's nearest styled label (star)
// find all the remaining labels (stars) and make those look "unchecked"
.star__input:checked + .star__label ~ .star__label {
color: var(--star-unchecked);
}
// do the same thing, but override when you hover on the star container
.star-container:hover .star__input:checked + .star__label ~ .star__label {
color: var(--star-hover);
}
// finally, force anything after the currently hovered star to override
// AGAIN to be white.
.star__label:hover ~ .star__label {
color: var(--star-unchecked) !important;
}
// Slight shift to really emphasize the click on this star
.star__label:active {
transform: translateY(1px);
}
// Focus states since we're hiding our native elements
.star__input:focus-visible + .star__label {
outline: 2px solid var(--outline-color);
outline-offset: 1px;
border-radius: 4px;
}
scss

Hope this helps you!