A Single-Element, CSS-Only Star Rating
Building a star rating widget is a classic front-end exercise, and most solutions lean on JavaScript to glue together markup and styling. But with modern CSS features like mask and border-image, you can build a fully interactive rating system with a single <input type="range"> element—no scripts, no extra wrappers required.
The core idea is to treat the range input as the entire component. Its min and max attributes define the boundaries (e.g., 1 to 5 stars), and we transform the native slider appearance into a star shape. While the implementation is clean from a CSS perspective, note that it’s not without accessibility quirks; the author themselves advise using it with caution.
The Challenge of Styling Native Inputs
Styling native form elements is notoriously tricky because browsers render them with different internal structures. Inspecting a range input in Chrome versus Firefox reveals distinct shadow DOMs. However, two common parts are consistent enough to target:
- The main element — the input itself.
- The thumb — the draggable handle.
This means we need pseudo-elements like ::-webkit-slider-thumb and ::-moz-range-thumb. A key gotcha: you cannot group these selectors in a single rule (like input::-webkit-slider-thumb, input::-moz-range-thumb { ... }) because if one browser doesn’t recognize a selector, the entire rule is invalid. The article simplifies by showing a single selector for readability, but the actual demo duplicates styles for each vendor prefix.
Building the Star Shape with mask
First, define the component’s dimensions:
input[type="range"] {
--s: 100px; /* control the size*/
height: var(--s);
aspect-ratio: 5;
appearance: none; /* remove the default browser styles */
}
A 5-star rating needs a width equal to five times its height, hence aspect-ratio: 5. That 5 value also corresponds to the input’s max attribute. This is where the enhanced attr() function (currently Chrome-only) shines—you can read the max value directly in CSS instead of hardcoding 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 */
}
This makes the component adaptable: change the max attribute, and the CSS updates automatically. For unsupported browsers, a fallback value is provided in the demos.
With the sizing in place, use CSS mask to draw the stars. The mask size is var(--s) (where --s is the star size), repeating horizontally. The mask itself can be created with either gradients or an inline SVG. While the SVG version is cleaner and shorter, gradients are worth knowing for cases where SVG is impractical:
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);
}
Positioning the Thumb for Perfect Alignment
By default, the range input’s thumb travels from the left edge to the right edge of the element. With 5 stars, the thumb’s position for a given value doesn’t align neatly with each star’s center—except at the extremes. To fix this, constrain the thumb’s travel area by adding horizontal padding equal to half a star’s width (var(--s)/2):
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);
}
Next, shrink the thumb itself to a 1px width and make it transparent. This turns the thumb into an invisible line marker that sits at the center of each star. The visual “fill” effect comes from border-image, which can draw decorations outside the element’s bounds.
The border-image source is a linear gradient with two solid color stops. By setting a large outset (e.g., 100px or more), the gradient extends far beyond the thumb. Since the gradient uses a 50% + var(--s)/2 stop point, the first color covers exactly the area up to the next star’s center, creating a seamless filled effect across the selected stars:
border-image: linear-gradient(90deg, gold 50%, grey 0) fill 0 // 0 100px;
An alternative is to use a conic gradient for the same effect. While border-image syntax can be dense, the full style block is surprisingly small:
<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;
}
Half-Star Ratings and Keyboard Support
Supporting half-star granularity requires only minor tweaks. Set the input’s step attribute to .5 and adjust the padding and gradient offset from var(--s)/2 to var(--s)/4. Better yet, use attr(step) to derive that value dynamically, making the same CSS work for both whole- and half-star steps:
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;
}
One significant accessibility catch: applying a mask removes the default focus outline, which is critical for keyboard users. In a single-element implementation, a quick recovery is to craft a more intricate mask that leaves a small transparent border zone, which lets a visible focus ring show through:
mask:
/* ... */ 0/var(--s),
conic-gradient(from 90deg at 2px 2px,#0000 25%,#000 0)
0 0/calc(100% - 2px) calc(100% - 2px);
Alternatively, wraps the input in a container that receives the focus styling via :focus-within, preserving the simpler mask setup if the markup allows it.
Beyond Stars: Reusable Shape Logic
Because the technique is fundamentally about masking, swapping the star shape for any other graphic is trivial. Replace the mask value with an SVG of a heart, a butterfly, or even a PNG image; the underlying interaction logic is unchanged.
It’s also possible to break out of repeating shapes entirely. For instance, a volume-control component uses a more complex mask configuration to create a signal-style icon, perfectly functional with the same core code structure.
At its heart, this technique is less a specific component and more a general method: treat the range input as a paint surface, mask it to a visual form, and use border-image to overflow the value indication. It’s a compelling reminder that native form controls can be radically restyled with a few lines of modern CSS.



