Fri, 07 Mar 2025 13:14:12 +0000
In the last article, we created a CSS-only star rating component using the CSS mask
and border-image
properties, as well as the newly enhanced attr()
function. We ended with CSS code that we can easily adjust to create component variations, including a heart rating and volume control.
This second article will study a different approach that gives us more flexibility. Instead of the border-image
trick we used in the first article, we will rely on scroll-driven animations!
Here is the same star rating component with the new implementation. And since we’re treading in experimental territory, you’ll want to view this in Chrome 115+ while we wait for Safari and Firefox support:
Do you spot the difference between this and the final demo in the first article? This time, I am updating the color of the stars based on how many of them are selected — something we cannot do using the border-image
trick!
I highly recommend you read the first article before jumping into this second part if you missed it, as I will be referring to concepts and techniques that we explored over there.
One more time: At the time of writing, only Chrome 115+ and Edge 115+ fully support the features we will be using in this article, so please use either one of those as you follow along.
Why scroll-driven animations?
You might be wondering why we’re talking about scroll-driven animation when there’s nothing to scroll to in the star rating component. Scrolling? Animation? But we have nothing to scroll or animate! It’s even more confusing when you read the MDN explainer for scroll-driven animations:
It allows you to animate property values based on a progression along a scroll-based timeline instead of the default time-based document timeline. This means that you can animate an element by scrolling a scrollable element, rather than just by the passing of time.
But if you keep reading you will see that we have two types of scroll-based timelines: scroll progress timelines and view progress timelines. In our case, we are going to use the second one; a view progress timeline, and here is how MDN describes it:
You progress this timeline based on the change in visibility of an element (known as the subject) inside a scroller. The visibility of the subject inside the scroller is tracked as a percentage of progress — by default, the timeline is at 0% when the subject is first visible at one edge of the scroller, and 100% when it reaches the opposite edge.
You can check out the CSS-Tricks almanac definition for view-timeline-name
while you’re at it for another explanation.
Things start to make more sense if we consider the thumb element as the subject and the input element as the scroller. After all, the thumb moves within the input area, so its visibility changes. We can track that movement as a percentage of progress and convert it to a value we can use to style the input element. We are essentially going to implement the equivalent of document.querySelector("input").value
in JavaScript but with vanilla CSS!
The implementation
Now that we have an idea of how this works, let’s see how everything translates into code.
@property --val {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
input[type="range"] {
--min: attr(min type(<number>));
--max: attr(max type(<number>));
timeline-scope: --val;
animation: --val linear both;
animation-timeline: --val;
animation-range: entry 100% exit 0%;
overflow: hidden;
}
@keyframes --val {
0% { --val: var(--max) }
100% { --val: var(--min) }
}
input[type="range"]::thumb {
view-timeline: --val inline;
}
I know, this is a lot of strange syntax! But we will dissect each line and you will see that it’s not all that complex at the end of the day.
The subject and the scroller
We start by defining the subject, i.e. the thumb element, and for this we use the view-timeline
shorthand property. From the MDN page, we can read:
The
view-timeline
CSS shorthand property is used to define a named view progress timeline, which is progressed through based on the change in visibility of an element (known as the subject) inside a scrollable element (scroller).view-timeline
is set on the subject.
I think it’s self-explanatory. The view timeline name is --val
and the axis is inline
since we’re working along the horizontal x-axis.
Next, we define the scroller, i.e. the input element, and for this, we use overflow: hidden
(or overflow: auto
). This part is the easiest but also the one you will forget the most so let me insist on this: don’t forget to define overflow on the scroller!
I insist on this because your code will work fine without defining overflow, but the values won’t be good. The reason is that the scroller exists but will be defined by the browser (depending on your page structure and your CSS) and most of the time it’s not the one you want. So let me repeat it another time: remember the overflow
property!
The animation
Next up, we create an animation that animates the --val
variable between the input’s min
and max
values. Like we did in the first article, we are using the newly-enhanced attr()
function to get those values. See that? The “animation” part of the scroll-driven animation, an animation we link to the view timeline we defined on the subject using animation-timeline
. And to be able to animate a variable we register it using @property
.
Note the use of timeline-scope
which is another tricky feature that’s easy to overlook. By default, named view timelines are scoped to the element where they are defined and its descendant. In our case, the input is a parent element of the thumb so it cannot access the named view timeline. To overcome this, we increase the scope using timeline-scope
. Again, from MDN:
timeline-scope
is given the name of a timeline defined on a descendant element; this causes the scope of the timeline to be increased to the element thattimeline-scope
is set on and any of its descendants. In other words, that element and any of its descendant elements can now be controlled using that timeline.
Never forget about this! Sometimes everything is correctly defined but nothing is working because you forget about the scope.
There’s something else you might be wondering:
Why are the keyframes values inverted? Why is the
min
is set to100%
and themax
set to0%
?
To understand this, let’s first take the following example where you can scroll the container horizontally to reveal a red circle inside of it.
Initially, the red circle is hidden on the right side. Once we start scrolling, it appears from the right side, then disappears to the left as you continue scrolling towards the right. We scroll from left to right but our actual movement is from right to left.
In our case, we don’t have any scrolling since our subject (the thumb) will not overflow the scroller (the input) but the main logic is the same. The starting point is the right side and the ending point is the left side. In other words, the animation starts when the thumb is on the right side (the input’s max
value) and will end when it’s on the left side (the input’s min
value).
The animation range
The last piece of the puzzle is the following important line of code:
animation-range: entry 100% exit 0%;
By default, the animation starts when the subject starts to enter the scroller from the right and ends when the subject has completely exited the scroller from the left. This is not good because, as we said, the thumb will not overflow the scroller, so it will never reach the start and the end of the animation.

To rectify this we use the animation-range
property to make the start of the animation when the subject has completely entered the scroller from the right (entry 100%
) and the end of the animation when the subject starts to exit the scroller from the left (exit 0%
).

To summarize, the thumb element will move within input’s area and that movement is used to control the progress of an animation that animates a variable between the input’s min
and max
attribute values. We have our replacement for document.querySelector("input").value
in JavaScript!
What’s going on with all the
--val
instances everywhere? Is it the same thing each time?
I am deliberately using the same --val
everywhere to confuse you a little and push you to try to understand what is going on. We usually use the dashed ident (--
) notation to define custom properties (also called CSS variables) that we later call with var()
. This is still true but that same notation can be used to name other things as well.
In our examples we have three different things named --val
:
- The variable that is animated and registered using
@property
. It contains the selected value and is used to style the input. - The named view timeline defined by
view-timeline
and used byanimation-timeline
. - The keyframes named
--val
and called byanimation
.
Here is the same code written with different names for more clarity:
@property --val {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
input[type="range"] {
--min: attr(min type(<number>));
--max: attr(max type(<number>));
timeline-scope: --timeline;
animation: value_update linear both;
animation-timeline: --timeline;
animation-range: entry 100% exit 0%;
overflow: hidden;
}
@keyframes value_update {
0% { --val: var(--max) }
100% { --val: var(--min) }
}
input[type="range"]::thumb {
view-timeline: --timeine inline;
}
The star rating component
All that we have done up to now is get the selected value of the input range — which is honestly about 90% of the work we need to do. What remains is some basic styles and code taken from what we made in the first article.
If we omit the code from the previous section and the code from the previous article here is what we are left with:
input[type="range"] {
background:
linear-gradient(90deg,
hsl(calc(30 + 4 * var(--val)) 100% 56%) calc(var(--val) * 100% / var(--max)),
#7b7b7b 0
);
}
input[type="range"]::thumb {
opacity: 0;
}
We make the thumb invisible and we define a gradient on the main element to color in the stars. No surprise here, but the gradient uses the same --val
variable that contains the selected value to inform how much is colored in.
When, for example, you select three stars, the --val
variable will equal 3
and the color stop of the first color will equal 3*100%/5
, or 60%
, meaning three stars are colored in. That same color is also dynamic as I am using the hsl()
function where the first argument (the hue) is a function of --val
as well.
Here is the full demo, which you will want to open in Chrome 115+ at the time I’m writing this:
And guess what? This implementation works with half stars as well without the need to change the CSS. All you have to do is update the input’s attributes to work in half increments. Remember, we’re yanking these values out of HTML into CSS using attr()
, which reads the attributes and returns them to us.
<input type="range" min=".5" step=".5" max="5">
That’s it! We have our rating star component that you can easily control by adjusting the attributes.
So, should I use border-image
or a scroll-driven animation?
If we look past the browser support factor, I consider this version better than the border-image
approach we used in the first article. The border-image
version is simpler and does the job pretty well, but it’s limited in what it can do. While our goal is to create a star rating component, it’s good to be able to do more and be able to style an input range as you want.
With scroll-driven animations, we have more flexibility since the idea is to first get the value of the input and then use it to style the element. I know it’s not easy to grasp but don’t worry about that. You will face scroll-driven animations more often in the future and it will become more familiar with time. This example will look easy to you in good time.
Worth noting, that the code used to get the value is a generic code that you can easily reuse even if you are not going to style the input itself. Getting the value of the input is independent of styling it.
Here is a demo where I am adding a tooltip to a range slider to show its value:
Many techniques are involved to create that demo and one of them is using scroll-driven animations to get the input value and show it inside the tooltip!
Here is another demo using the same technique where different range sliders are controlling different variables on the page.
And why not a wavy range slider?
This one is a bit crazy but it illustrates how far we go with styling an input range! So, even if your goal is not to create a star rating component, there are a lot of use cases where such a technique can be really useful.
Conclusion
I hope you enjoyed this brief two-part series. In addition to a star rating component made with minimal code, we have explored a lot of cool and modern features, including the attr()
function, CSS mask
, and scroll-driven animations. It’s still early to adopt all of these features in production because of browser support, but it’s a good time to explore them and see what can be done soon using only CSS.
Article series
- A CSS-Only Star Rating Component and More! (Part 1)
- A CSS-Only Star Rating Component and More! (Part 2)
A CSS-Only Star Rating Component and More! (Part 2) originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Recommended Comments