Creating a star rating component is a classic exercise in web development. It has been done and re-done many times using different techniques. We usually need a small amount of JavaScript to pull it together, but what about a CSS-only implementation? Yes, it is possible!
Here is a demo of a CSS-only star rating component. You can click to update the rating.
Cool, right? In addition to being CSS-only, the HTML code is nothing but a single element:
<input type="range" min="1" max="5">
An input range element is the perfect candidate here since it allows a user to select a numeric value between two boundaries (the min
and max
). Our goal is to style that native element and transform it into a star rating component without additional markup or any script! We will also create more components at the end, so follow along.
Note: This article will only focus on the CSS part. While I try my best to consider UI, UX, and accessibility aspects, my component is not perfect. It may have some drawbacks (bugs, accessibility issues, etc), so please use it with caution.
The <input>
element
You probably know it but styling native elements such as inputs is a bit tricky due to all the default browser styles and also the different internal structures. If, for example, you inspect the code of an input range you will see a different HTML between Chrome (or Safari, or Edge) and Firefox.
Luckily, we have some common parts that I will rely on. I will target two different elements: the main element (the input itself) and the thumb element (the one you slide with your mouse to update the value).
Our CSS will mainly look like this:
input[type="range"] {
/* styling the main element */
}
input[type="range" i]::-webkit-slider-thumb {
/* styling the thumb for Chrome, Safari and Edge */
}
input[type="range"]::-moz-range-thumb {
/* styling the thumb for Firefox */
}
The only drawback is that we need to repeat the styles of the thumb element twice. Don’t try to do the following:
input[type="range" i]::-webkit-slider-thumb,
input[type="range"]::-moz-range-thumb {
/* styling the thumb */
}
This doesn’t work because the whole selector is invalid. Chrome & Co. don’t understand the ::-moz-*
part and Firefox doesn’t understand the ::-webkit-*
part. For the sake of simplicity, I will use the following selector for this article:
input[type="range"]::thumb {
/* styling the thumb */
}
But the demo contains the real selectors with the duplicated styles. Enough introduction, let’s start coding!
Styling the main element (the star shape)
We start by defining the size:
input[type="range"] {
--s: 100px; /* control the size*/
height: var(--s);
aspect-ratio: 5;
appearance: none; /* remove the default browser styles */
}
If we consider that each star is placed within a square area, then for a 5-star rating we need a width equal to five times the height, hence the use of aspect-ratio: 5
.
That 5
value is also the value defined as the max
attribute for the input element.
<input type="range" min="1" max="5">
So, we can rely on the newly enhanced attr()
function (Chrome-only at the moment) to read that value instead of manually defining it!
input[type="range"] {
--s: 100px; /* control the size*/
height: var(--s);
aspect-ratio: attr(max type(<number>));
appearance: none; /* remove the default browser styles */
}
Now you can control the number of stars by simply adjusting the max
attribute. This is great because the max
attribute is also used by the browser internally, so updating that value will control our implementation as well as the browser’s behavior.
This enhanced version of attr()
is only available in Chrome for now so all my demos will contain a fallback to help with unsupported browsers.
The next step is to use a CSS mask
to create the stars. We need the shape to repeat five times (or more depending on the max
value) so the mask size should be equal to var(--s) var(--s)
or var(--s) 100%
or simply var(--s)
since by default the height will be equal to 100%
.
input[type="range"] {
--s: 100px; /* control the size*/
height: var(--s);
aspect-ratio: attr(max type(<number>));
appearance: none; /* remove the default browser styles */
mask-image: /* ... */;
mask-size: var(--s);
}
What about the mask-image
property you might ask? I think it’s no surprise that I tell you it will require a few gradients, but it could also be SVG instead. This article is about creating a star-rating component but I would like to keep the star part kind of generic so you can easily replace it with any shape you want. That’s why I say “and more” in the title of this post. We will see later how using the same code structure we can get a variety of different variations.
Here is a demo showing two different implementations for the star. One is using gradients and the other is using an SVG.
In this case, the SVG implementation looks cleaner and the code is also shorter but keep both approaches in your back pocket because a gradient implementation can do a better job in some situations.
Styling the thumb (the selected value)
Let’s now focus on the thumb element. Take the last demo then click the stars and notice the position of the thumb.
The good thing is that the thumb is always within the area of a given star for all the values (from min
to max
), but the position is different for each star. It would be good if the position is always the same, regardless of the value. Ideally, the thumb should always be at the center of the stars for consistency.
Here is a figure to illustrate the position and how to update it.

The lines are the position of the thumb for each value. On the left, we have the default positions where the thumb goes from the left edge to the right edge of the main element. On the right, if we restrict the position of the thumb to a smaller area by adding some spaces on the sides, we get much better alignment. That space is equal to half the size of one star, or var(--s)/2
. We can use padding for this:
input[type="range"] {
--s: 100px; /* control the size */
height: var(--s);
aspect-ratio: attr(max type(<number>));
padding-inline: calc(var(--s) / 2);
box-sizing: border-box;
appearance: none; /* remove the default browser styles */
mask-image: ...;
mask-size: var(--s);
}
It’s better but not perfect because I am not accounting for the thumb size, which means we don’t have true centering. It’s not an issue because I will make the size of the thumb very small with a width equal to 1px
.
input[type="range"]::thumb {
width: 1px;
height: var(--s);
appearance: none; /* remove the default browser styles */
}
The thumb is now a thin line placed at the center of the stars. I am using a red color to highlight the position but in reality, I don’t need any color because it will be transparent.
You may think we are still far from the final result but we are almost done! One property is missing to complete the puzzle: border-image
.
The border-image
property allows us to draw decorations outside an element thanks to its outset feature. For this reason, I made the thumb small and transparent. The coloration will be done using border-image
. I will use a gradient with two solid colors as the source:
linear-gradient(90deg, gold 50%, grey 0);
And we write the following:
border-image: linear-gradient(90deg, gold 50%, grey 0) fill 0 // 0 100px;
The above means that we extend the area of the border-image
from each side of the element by 100px
and the gradient will fill that area. In other words, each color of the gradient will cover half of that area, which is 100px
.
Do you see the logic? We created a kind of overflowing coloration on each side of the thumb — a coloration that will logically follow the thumb so each time you click a star it slides into place!
Now instead of 100px
let’s use a very big value:
We are getting close! The coloration is filling all the stars but we don’t want it to be in the middle but rather across the entire selected star. For this, we update the gradient a bit and instead of using 50%
, we use 50% + var(--s)/2
. We add an offset equal to half the width of a star which means the first color will take more space and our star rating component is perfect!
We can still optimize the code a little where instead of defining a height for the thumb, we keep it 0
and we consider the vertical outset of border-image
to spread the coloration.
input[type="range"]::thumb{
width: 1px;
border-image:
linear-gradient(90deg, gold calc(50% + var(--s) / 2), grey 0)
fill 0 // var(--s) 500px;
appearance: none;
}
We can also write the gradient differently using a conic gradient instead:
input[type="range"]::thumb{
width: 1px;
border-image:
conic-gradient(at calc(50% + var(--s) / 2), grey 50%, gold 0)
fill 0 // var(--s) 500px;
appearance: none;
}
I know that the syntax of border-image
is not easy to grasp and I went a bit fast with the explanation. But I have a very detailed article over at Smashing Magazine where I dissect that property with a lot of examples that I invite you to read for a deeper dive into how the property works.
The full code of our component is this:
<input type="range" min="1" max="5">
input[type="range"] {
--s: 100px; /* control the size*/
height: var(--s);
aspect-ratio: attr(max type(<number>));
padding-inline: calc(var(--s) / 2);
box-sizing: border-box;
appearance: none;
mask-image: /* ... */; /* either an SVG or gradients */
mask-size: var(--s);
}
input[type="range"]::thumb {
width: 1px;
border-image:
conic-gradient(at calc(50% + var(--s) / 2), grey 50%, gold 0)
fill 0//var(--s) 500px;
appearance: none;
}
That’s all! A few lines of CSS code and we have a nice rating star component!
Half-Star Rating
What about having a granularity of half a star as a rating? It’s something common and we can do it with the previous code by making a few adjustments.
First, we update the input element to increment in half step
s instead of full steps:
<input type="range" min=".5" step=".5" max="5">
By default, the step is equal to 1
but we can update it to .5
(or any value) then we update the min
value to .5
as well. On the CSS side, we change the padding from var(--s)/2
to var(--s)/4
, and we do the same for the offset inside the gradient.
input[type="range"] {
--s: 100px; /* control the size*/
height: var(--s);
aspect-ratio: attr(max type(<number>));
padding-inline: calc(var(--s) / 4);
box-sizing: border-box;
appearance: none;
mask-image: ...; /* either SVG or gradients */
mask-size: var(--s);
}
input[type="range"]::thumb{
width: 1px;
border-image:
conic-gradient(at calc(50% + var(--s) / 4),grey 50%, gold 0)
fill 0 // var(--s) 500px;
appearance: none;
}
The difference between the two implementations is a factor of one-half which is also the step
value. That means we can use attr()
and create a generic code that works for both cases.
input[type="range"] {
--s: 100px; /* control the size*/
--_s: calc(attr(step type(<number>),1) * var(--s) / 2);
height: var(--s);
aspect-ratio: attr(max type(<number>));
padding-inline: var(--_s);
box-sizing: border-box;
appearance: none;
mask-image: ...; /* either an SVG or gradients */
mask-size: var(--s);
}
input[type="range"]::thumb{
width: 1px;
border-image:
conic-gradient(at calc(50% + var(--_s)),gold 50%,grey 0)
fill 0//var(--s) 500px;
appearance: none;
}
Here is a demo where modifying the step is all that you need to do to control the granularity. Don’t forget that you can also control the number of stars using the max
attribute.
Using the keyboard to adjust the rating
As you may know, we can adjust the value of an input range slider using a keyboard, so we can control the rating using the keyboard as well. That’s a good thing but there is a caveat. Due to the use of the mask
property, we no longer have the default outline that indicates keyboard focus which is an accessibility concern for those who rely on keyboard input.
For a better user experience and to make the component more accessible, it’s good to display an outline on focus. The easiest solution is to add an extra wrapper:
<span>
<input type="range" min="1" max="5">
</span>
That will have an outline when the input inside has focus:
span:has(:focus-visible) {
outline: 2px solid;
}
Try to use your keyboard in the below example to adjust both ratings:
Another idea is to consider a more complex mask
configuration that prevents hiding the outline (or any outside decoration). The trick is to start with the following:
mask:
conic-gradient(#000 0 0) exclude,
conic-gradient(#000 0 0) no-clip;
The no-clip
keyword means that nothing from the element will be clipped (including outlines). Then we use an exclude
composition with another gradient. The exclusion will hide everything inside the element while keeping what is outside visible.
Finally, we add back the mask that creates the star shapes:
mask:
/* ... */ 0/var(--s),
conic-gradient(#000 0 0) exclude,
conic-gradient(#000 0 0) no-clip;
I prefer using this last method because it maintains the single-element implementation but maybe your HTML structure allows you to add focus on an upper element and you can keep the mask configuration simple. It totally depends!
Credits to Ana Tudor for the last trick!
More examples!
As I said earlier, what we are making is more than a star rating component. You can easily update the mask value to use any shape you want.
Here is an example where I am using an SVG of a heart instead of a star.
Why not butterflies?
This time I am using a PNG image as a mask. If you are not comfortable using SVG or gradients you can use a transparent image instead. As long as you have an SVG, a PNG, or gradients, there is no limit on what you can do with this as far as shapes go.
We can go even further into the customization and create a volume control component like below:
I am not repeating a specific shape in that last example, but am using a complex mask
configuration to create a signal shape.
Conclusion
We started with a star rating component and ended with a bunch of cool examples. The title could have been “How to style an input range element” because this is what we did. We upgraded a native component without any script or extra markup, and with only a few lines of CSS.
What about you? Can you think about another fancy component using the same code structure? Share your example in the comment section!
Article series
- A CSS-Only Star Rating Component and More! (Part 1)
- A CSS-Only Star Rating Component and More! (Part 2) — Coming March 7!