Rating components without JavaScript: the view-timeline method
In the first part of this series, we built a CSS-only star rating component with mask, border-image, and the enhanced attr() function. That approach works well, but it has a ceiling: you can't recolor the stars dynamically based on the selected value.
This second part introduces a fundamentally different technique using scroll-driven animations. More precisely, we'll leverage a view progress timeline to read a range input's value in pure CSS. This unlocks what was previously impossible with the border-image approach: updating star colors based on the rating.
At the time of writing, the required features are fully supported only in Chrome 115+ and Edge 115+. Safari and Firefox support is pending.
How a view progress timeline replaces JavaScript
A common description of scroll-driven animations focuses on tracking an element's position within a scrolling container. That's accurate but sounds irrelevant to anything that doesn't actually scroll. The trick is to recognize that the thumb of a range input is a subject moving within a scroller — the input itself.
The input's min and max attributes define the subject's path. As the thumb moves from one extreme to the other, its visibility across the input's scrollport changes. Interpreting that movement as a progress percentage gives us an equivalent of document.querySelector("input").value — rendered purely in CSS.
Setting up subject and scroller
First, declare the thumb as a subject with view-timeline. This shorthand defines a named view progress timeline and sets its axis:
@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;
}
- Subject: the thumb element, which carries
view-timeline. The timeline name is--valand the axis isinlinebecause we are working along the horizontal x-axis. - Scroller: the input element, which needs
overflow: hidden(orauto). This is essential. Without an explicit overflow, the browser chooses an implicit scroller — usually not the one you want — and the computed values will be wrong.
Animating a variable between min and max
The timeline is useful only in combination with an animation. We define keyframes that animate a registered custom property, --val, between the input's min and max, read via the enhanced attr() function.
Two subtle requirements often get missed:
- Use
@propertyto register the variable. Unregistered custom properties are not animatable. - Set
timeline-scope. Named view timelines are, by default, scoped to the subject's element and its descendants. Since the input is a parent of the thumb, the input cannot see its descendant's timeline.timeline-scopelifts the timeline's scope to the element that declares it and all its descendants.
The keyframes look counter-intuitive at first glance:
Why is theminvalue placed at100%and themaxvalue at0%?
Consider a horizontal scroll container with an element inside it. Initially, the element is hidden to the right. As you scroll left to right, the element appears from the right and leaves toward the left. Your scrolling direction is the opposite of the element's actual motion.
The same logic applies to the range input. Even though nothing visually scrolls, the subject's starting edge is on the right — the input's max — and its ending edge is on the left — the min.
Fixing the animation range
The default view timeline range runs from the moment the subject enters the scroller from the right until it fully exits on the left. Since the thumb never overflows the input, that default span never gets fully traversed.
The animation-range property fixes this with these two values:

- Start the animation at
entry 100%, the point when the thumb has fully entered the input from the right. - End it at
exit 0%, the moment the thumb begins to leave toward the left.
The result: the thumb's path within the input is fully mapped to an animation between min and max.
Why all the same name?
The repeated --val identifier is not one thing but three. The dashed notation is shared across:
- The animated custom property, registered via
@property, which holds the selected value and drives styling. - The named view timeline, created by
view-timelineand consumed byanimation-timeline. - The keyframes name,
--val, referenced by theanimationshorthand.
Renaming each separation makes the code more readable:
@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;
}
Styling the stars
Reading the range input's value is roughly 90% of the work. The remaining CSS overlays and colors the stars. This wraps together pieces from the previous article plus the new value-reading mechanism:
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;
}
The thumb is made invisible. A gradient on the parent element fills the star shapes. The gradient's color stops use --val to determine how many stars are filled. With three stars selected, --val equals 3, and the first gradient color stops at 3 * 100% / 5, i.e., 60%.
Because the gradient is driven by the variable, even subtle updates — like animating the color's hue with hsl() — become possible.
The technique also adapts cleanly to partial values. Allowing half-star ratings requires nothing in the CSS; just change the input's attributes to step in increments of 0.5:
<input type="range" min=".5" step=".5" max="5">
Old technique or new technique?
Ignoring the browser support situation, a scroll-driven animation approach is a strictly more flexible replacement for the border-image method. The border-image version is simpler and adequate for a star rating, but the value-reading CSS is itself reusable beyond styling the input. Extracting the input's value is fully independent of what comes next.
That separation makes the technique broadly applicable:
- A styling layer can use the value to update any element, not just the scroll subject — for example, showing a range slider's current value in a tooltip.
- The same value can drive distinct sliders that mutate unrelated properties across an entire layout.
- Because only the generic computation is reused, a widget built purely for stars works as well for wavy sliders or any input styling that benefits from reactivity.
Readability and browser support remain the main hurdles, but the pattern is worth experimenting with now, in anticipation of native support arriving in Safari and Firefox.



