A CSS-Only Star Rating Component and More (Part 1)

Build interactive UI components without JavaScript using modern CSS techniques

Why CSS-Only Components Matter

Building interactive user interface components has traditionally required significant JavaScript code. But modern CSS capabilities have changed the game. By leveraging native browser functionality and advanced CSS techniques, we can create fully functional, interactive components without writing a single line of script logic.

The star rating component is a perfect example. It's one of the most common UI elements on the web, yet it has always been built with JavaScript assistance. In this guide, we'll explore how to build a complete, interactive star rating using only HTML and CSS.

What You'll Learn

  • How to use the native range input element as the foundation - The range input provides built-in min/max/step controls, keyboard navigation, and touch support without any additional code
  • Creating complex shapes with CSS mask-image and conic gradients - Master the art of building geometric shapes using pure CSS gradients that define which parts of an element are visible
  • Coloring selected elements with border-image and gradients - Learn how to use the border-image property with conic gradients to create dynamic color transitions that follow user interaction
  • Supporting half-star ratings through step attribute manipulation - Extend the component to support granular feedback with simple attribute changes and corresponding CSS adjustments
  • Ensuring keyboard accessibility without extra code - Leverage native range input accessibility while adding proper focus indicators and ARIA labels for screen reader support
  • Extending the technique to other shapes and components - Apply the same principles to create heart ratings, volume controls, progress indicators, and custom brand elements

The Performance Advantage

CSS-only components offer significant performance benefits for production websites:

  • Zero JavaScript dependencies for core functionality - The component works without any script files, reducing network requests and eliminating potential runtime errors
  • Smaller bundle sizes and faster page loads - Removing JavaScript libraries for simple interactive elements keeps your bundles lean and your initial load times fast
  • GPU-accelerated CSS animations - CSS properties like mask-image and border-image are hardware-accelerated in modern browsers, providing smooth 60fps performance
  • Better browser performance overall - Without JavaScript event listeners and DOM updates, the browser has fewer tasks to manage during user interaction
The Single Element Foundation
1<input type="range" min="1" max="5" value="3">

The Foundation: Understanding the Range Input

The <input type="range"> element is the secret weapon behind CSS-only rating components. It's a native HTML element designed specifically for selecting numeric values within a defined range, and it comes with built-in functionality that we'd otherwise have to code ourselves.

Why Range Input Works Perfectly

The range input element is the ideal foundation for interactive components because it provides essential functionality out of the box. Built-in min/max/step attributes let browsers handle value constraints automatically, preventing users from selecting values outside your defined range without any validation logic. Native keyboard support means users can navigate and change values using arrow keys, Page Up, Page Down, Home, and End keys--all without JavaScript event handlers. Touch and mouse interaction works seamlessly across desktop and mobile devices, with drag-and-drop functionality that feels natural on each platform. Cross-browser support is excellent for range inputs, meaning the underlying functionality works consistently in Chrome, Firefox, Safari, and Edge. Finally, form integration comes for free--the range input is a native form element that participates in form submission and validation automatically.

The Core HTML Structure

The range input accepts several key attributes that control its behavior. The min attribute sets the minimum value (default is 0), while max defines the maximum value (default is 100). The step attribute controls the increment between values--for whole stars, use step="1", and for half stars, use step="0.5"). The value attribute sets the initial value and is also what gets submitted with the form.

AttributePurposeExample
minMinimum valuemin="1"
maxMaximum valuemax="5"
stepIncrement valuestep="0.5"
valueInitial valuevalue="3"

Browser Differences in Styling

While the range input works consistently across browsers for functionality, the internal structure that browsers expose for styling differs significantly. Chrome, Safari, and Edge use WebKit-based rendering and require ::-webkit-slider-thumb for the draggable handle and ::-webkit-slider-runnable-track for the track. Firefox uses its own Gecko engine and requires ::-moz-range-thumb and ::-moz-range-track pseudo-elements. This means we need to duplicate certain styles for cross-browser compatibility, but the core HTML structure and JavaScript-free functionality remains the same across all browsers. Understanding these differences ensures your component looks and works consistently for all users.

Creating Star Shapes with CSS Masks

The magic of CSS-only star ratings lies in the mask-image property. Unlike background-image which paints content on top of an element, CSS masks determine which parts of an element are visible based on the mask content--this is a fundamental difference that makes the technique work.

How CSS Masks Work

The mask-image property accepts gradients, images, or SVG data just like background-image, but operates in reverse. Where pixels in the mask are opaque, the underlying element shows through and remains visible. Where pixels are transparent, the element is completely hidden. This "inverse painting" behavior allows us to take a simple rectangular input element and carve out any shape we want. The mask-size property controls how the mask scales, and mask-repeat determines if the pattern tiles. For star ratings, we set mask-size to match our desired star dimensions and use no-repeat to prevent tiling. Browser support for mask-image is excellent in modern browsers, making this technique viable for production use.

Building the Star with Conic Gradients

Creating a perfect five-pointed star using pure CSS requires stacking multiple conic gradients, each carefully positioned to cut away portions of a square until only the star remains. Each conic gradient creates a triangular "cut" by going from transparent to black at specific angles. By combining five of these cuts, positioned at precise coordinates around the center, we transform a square into a geometrically accurate five-pointed star. The syntax looks complex but follows a consistent pattern: each gradient defines a starting angle, a center point using percentage coordinates, and the angle range for the cut. The result is stored in a CSS custom property for reuse, making the mask easy to apply to any element.

--star-mask: 
 conic-gradient(from -18deg at 61% 34.5%, #0000 108deg, #000 0) 0 0/var(--size) var(--size),
 conic-gradient(from 270deg at 39% 34.5%, #0000 108deg, #000 0) 0 0/var(--size) var(--size),
 conic-gradient(from 54deg at 68% 56%, #0000 108deg, #000 0) 0 0/var(--size) var(--size),
 conic-gradient(from 198deg at 32% 56%, #0000 108deg, #000 0) 0 0/var(--size) var(--size),
 conic-gradient(from 126deg at 50% 69%, #0000 108deg, #000 0) 0 0/var(--size) var(--size);

Alternative Approaches

For teams uncomfortable with complex gradient math or who need maximum browser compatibility, several alternatives exist. SVG masks allow you to define the star shape in SVG markup, then reference that SVG as the mask image--this is often more readable and easier to customize. PNG images using a transparent star PNG as the mask provide universal compatibility, though they add an external asset to load. Simpler shapes like circles or squares require much simpler gradients or no gradients at all, making them a good starting point for learning the technique. The gradient approach offers the advantage of being pure CSS with no external dependencies, keeping your project self-contained and performant.

Coloring Selected Stars with Border-Image

Now for the cleverest part: making the stars change color based on the selected rating without any JavaScript logic driving the visual updates.

The Border-Image Trick

The border-image property typically draws a border around an element using an image, but when combined with the fill keyword, something remarkable happens. The fill keyword extends the border-image to fill the entire element interior, not just the border area. Combined with the outset values, we can extend this fill beyond the element's normal bounds. This allows the gradient to cover the entire star shape and, crucially, to position itself based on where the gradient starts. By using a conic gradient split between two colors--gold for selected and gray for unselected--we create the visual appearance of stars filling from left to right as the user drags the input.

border-image: 
 conic-gradient(
 at calc(50% + var(--size) / 2),
 gold 50%,
 gray 0
 )
 fill 0 // var(--size) 500px;

How the Coloring Works

The conic gradient creates a split between two colors radiating from a central point, and the at calc(50% + var(--size) / 2) positions that split point horizontally based on star size. As the user drags the range input's thumb, the underlying value changes, and the CSS custom property tracking that value adjusts the gradient's position. The border-image fill follows this position, creating the effect of stars changing color as the selection moves. This is what makes the technique feel magical--the visual feedback is instantaneous and smooth, entirely driven by CSS, while JavaScript merely reports the current value. The calculation uses CSS calc() to determine exactly where the color transition should occur relative to each star's position.

Connecting JavaScript Value to CSS

While the visual rendering is entirely CSS-based, we do need one small piece of JavaScript to bind the input's current value to a CSS custom property that the gradient can reference. This is purely for state synchronization--not for DOM manipulation or complex logic. The inline oninput handler updates the --val custom property whenever the value changes, and the CSS uses this value to position the gradient split. In frameworks like React or Vue, you'd use the framework's binding syntax (such as :style or style props) to achieve the same result. The key insight is that JavaScript's role is minimal and declarative--it simply reports state changes rather than handling visual updates.

Styling the Thumb for Perfect Positioning

The range input's thumb (the draggable handle that users drag to change values) plays a crucial role in our CSS-only component. By styling it to be nearly invisible while maintaining precise positioning, we can create a seamless rating experience that feels like clicking directly on stars.

Making the Thumb Invisible

We want the thumb to control the coloring effect without being visible itself. The appearance: none property removes the browser's default thumb styling, and setting width to 1 pixel makes it essentially invisible while still allowing it to receive interaction events. The transparent background ensures no visual artifact remains. We repeat this for both WebKit browsers (Chrome, Safari, Edge) and Firefox using their respective pseudo-elements, ensuring consistent behavior across all modern browsers. This creates what appears to be a direct interaction with stars while the actual interaction target remains hidden beneath them.

input[type="range"]::-webkit-slider-thumb {
 appearance: none;
 width: 1px;
 height: var(--size);
 background: transparent;
}

input[type="range"]::-moz-range-thumb {
 appearance: none;
 width: 1px;
 height: var(--size);
 background: transparent;
}

Padding for Proper Alignment

By default, the range input's thumb positions itself at the edge of the element, which would place it at the left edge of each star rather than centered within it. This creates an off-center selection effect that feels wrong to users. By adding padding-inline equal to half the star size, we center each star relative to the thumb's position. This creates a 1:1 mapping between thumb positions and star centers, making the interaction feel natural and intuitive. The box-sizing: border-box property ensures the padding is included in the element's total width calculation, preventing layout shifts.

Cross-Browser Considerations

Due to fundamental differences in how WebKit and Gecko browsers implement range input internals within their shadow DOM structures, we must repeat certain styles for both browser engines. While this seems redundant, it ensures consistent behavior across Chrome, Safari, Edge, and Firefox. The key differences include the pseudo-element names (::-webkit-slider-thumb vs ::-moz-range-thumb) and sometimes subtle differences in default sizing and positioning. Testing on actual browsers is essential, as browser dev tools may not accurately represent the rendered shadow DOM structure. The good news is that once you have working styles for both engines, the component behaves identically for all users regardless of their browser choice.

Supporting Half-Star Ratings

Many rating systems benefit from half-star increments for more precise feedback. Our CSS-only approach adapts elegantly to this requirement with only minor attribute and CSS adjustments.

Adjusting the Step Value

To support half-star ratings, we simply change the step attribute from 1 to 0.5, allowing the input to accept values in increments of one-half. We also adjust the minimum value to 0.5 rather than 1, creating a clean mapping where each star position corresponds to a valid input value. This means users can select 1, 1.5, 2, 2.5, and so on up to 5 stars. The browser handles all value validation automatically--no JavaScript required to enforce the step increment.

<input 
 type="range" 
 min="0.5" 
 max="5" 
 step="0.5" 
 value="2.5"
>

Updating CSS Calculations

The CSS calculations need to account for the smaller step size, which affects how we calculate padding for proper alignment. The padding-inline value should be half the step value times the star size. Using the enhanced attr() function, we can read the step attribute directly in CSS, creating a truly dynamic component that adapts to its configuration automatically. This approach means you can change the step attribute without manually updating any CSS--the component recalculates its own layout.

input[type="range"] {
 --_s: calc(attr(step type(<number>),1) * var(--size) / 2);
 padding-inline: var(--_s);
}

Why Minimum is 0.5 (Not 0)

Setting the minimum to 0.5 instead of 0 is a deliberate design decision. With a 0 minimum and step of 0.5, we'd have 11 possible values (0 through 5 in 0.5 increments), but only 10 visual star positions (0.5 through 5). This mismatch creates confusing click targets where two different values map to the same visual position. Starting at 0.5 creates a clean 1:1 mapping between values and star positions, making the interaction intuitive and predictable. Users understand that clicking the first star gives them 1 star, the second gives 2 stars, and so on.

Accessibility: Keyboard Navigation and Focus

A truly robust component must be accessible to all users, including those who rely on keyboard navigation or assistive technologies. The good news is that the range input provides excellent accessibility foundations that we can enhance.

Native Keyboard Support

The range input element provides comprehensive keyboard accessibility automatically, without any additional code. Arrow keys (left/right or up/down) move the value by one step in the appropriate direction. Page Up and Page Down keys typically move by larger increments, though this behavior varies by browser. Home and End keys jump directly to the minimum and maximum values respectively. This functionality works universally across all browsers and requires no JavaScript event handling, ARIA attributes, or custom keybindings. Users who cannot use a mouse have full access to the rating functionality through these standard keyboard interactions.

Adding Visible Focus Indicators

Since our star masking hides the default browser focus outline (which appears around the entire input), we need to provide our own visual focus indicator for keyboard users. The :focus-visible pseudo-class helps us show focus styles only when they're useful--on keyboard navigation but not when clicking with a mouse. We wrap the input in a span element and apply focus styles to the wrapper, creating a clear visual indication of which component has focus. This approach keeps the star appearance clean for mouse users while ensuring keyboard users always know which component they're interacting with.

span:has(:focus-visible) {
 outline: 2px solid gold;
 outline-offset: 4px;
}

/* HTML: <span><input type="range"></span> */

Screen Reader Considerations

For screen reader users, we should add appropriate ARIA attributes that describe the component's purpose and current state. The aria-label provides a human-readable name for the input. The aria-valuemin, aria-valuemax, and aria-valuenow attributes communicate the current value to screen readers in a format they understand. These attributes should be updated programmatically when the value changes, ensuring screen readers always report the correct current rating. Without these attributes, screen readers may fall back to announcing generic "slider" without providing context about what the slider controls.

Color Contrast

Ensuring sufficient color contrast between the selected (gold) and unselected (gray) star colors, and between stars and the background, is essential for users with visual impairments. WCAG 2.1 Level AA requires a contrast ratio of at least 4.5:1 for text and 3:1 for large graphics and user interface components. Test your color choices using tools like the WebAIM Contrast Checker, and consider providing a high-contrast mode that uses darker, more saturated colors for users who need it.

Beyond Stars: Creating Other Interactive Components

The technique we've explored isn't limited to star ratings. The same fundamental approach--using a range input with CSS masks and border-image--can create a variety of interactive components by simply changing the mask shape.

Heart Ratings

Replace the star mask with a heart shape for like/dislike systems, favoriting functionality, or "heart" reactions similar to social media platforms. The heart shape can be created using similar conic gradient techniques or by using an SVG data URI as the mask. This opens up possibilities for engagement features where users express preference rather than numerical ratings. The implementation is identical--just swap the star mask for a heart mask and adjust colors to match your brand.

Volume Controls

Use a speaker icon or meter shape for volume controls, progress indicators, or skill level displays. A speaker silhouette mask creates an immediately recognizable volume slider that feels more engaging than a standard progress bar. The gradient fills from left to right as volume increases, providing intuitive visual feedback. This approach works particularly well for media applications, gaming interfaces, or any product where adjusting levels is a common interaction.

Custom Brand Elements

Companies can use their custom icons as the mask shape to create branded interactive elements. Product-specific icons, brand mascot characters, or themed shapes for special occasions all work with this technique. An e-commerce site might use shopping cart icons for a "cart commitment" meter, while a fitness app could use dumbbell icons for a workout progress indicator. The only requirement is that the mask shape can be expressed as an image, gradient, or SVG data URI.

Progress Indicators

The same technique works for progress indicators with custom shapes, useful for gamification elements, achievement displays, or milestone trackers. Rather than traditional rectangular progress bars, you can create progress displays using thematic shapes that match your application's narrative. This adds visual interest while maintaining the performance benefits of a CSS-only implementation. The progress fills dynamically as users advance, creating satisfying visual feedback.

Code Example: Custom Shape

input[type="range"] {
 --size: 48px;
 mask-image: url('data:image/svg+xml,<svg ... custom shape ...>');
 mask-size: var(--size);
}

The only requirement is that the mask shape can be expressed as an image, gradient, or SVG data URI. Everything else about the component--value handling, keyboard navigation, accessibility--works exactly the same way.

Performance and Browser Compatibility

Before deploying a CSS-only component, understanding its performance characteristics and browser support ensures a smooth user experience across all your visitors.

Browser Support

CSS-only star rating components benefit from excellent browser support for the key technologies involved. Mask-image is supported in all modern browsers including Chrome, Firefox, Safari, and Edge, making it a safe choice for production websites. Border-image has similarly excellent support across modern browsers and has been stable for many years. CSS custom properties (variables) are universally supported and enable the dynamic configuration that makes our component adaptable. The enhanced attr() function with type validation has more limited support--it's currently partially supported in Chrome with Firefox and Safari support still developing--so you may want to use explicit CSS variables for maximum compatibility.

FeatureChromeFirefoxSafariEdge
mask-image
border-image
CSS custom properties
Enhanced attr()✓ (partial)--✓ (partial)

Performance Characteristics

CSS-only components offer significant performance advantages over JavaScript-based alternatives. No JavaScript execution means the browser handles everything using its native rendering engine, which is highly optimized for these operations. GPU acceleration applies to CSS transforms and many CSS properties, allowing the browser to use hardware acceleration for smooth performance. No event listeners eliminates the memory overhead and potential performance degradation that comes with adding numerous event handlers to the DOM. Smaller bundle size reduces download time, parse time, and initial execution time--all critical metrics for Core Web Vitals and SEO rankings.

Fallback Strategies

For browsers or situations where the full CSS-only experience isn't available, consider implementing graceful degradation. Feature detection using @supports checks if the browser understands the required properties before applying the advanced styles. Progressive enhancement means starting with basic functionality and adding enhanced styles only when supported--this ensures all users can complete the core task. For very old browsers, you might provide a traditional <select>-based fallback that still functions but lacks the visual appeal. The key is ensuring users can always complete their goal, even if the experience looks different.

@supports (mask-image: conic-gradient(#000, #000)) {
 /* CSS-only star rating styles */
}

Mobile Considerations

Mobile users have unique needs that should inform your implementation. Touch target size is critical--ensure each star is at least 44x44 pixels to meet accessibility guidelines and provide a comfortable touch target. Test on actual devices rather than relying solely on browser dev tools, as touch behavior can differ significantly from mouse interaction. Consider adding larger hit areas around the component by wrapping it in a larger container with padding, making it easier to activate on touch screens where precision is lower than mouse input.

Best Practices and Implementation Tips

Following these guidelines ensures maintainable, accessible, and performant CSS-only components that will serve your users well for years to come.

CSS Architecture

Organize your CSS to promote maintainability and future modifications. Use custom properties for configuration by defining --size, --color-selected, --color-unselected, and --step at the component level where they can be easily discovered and modified. Separate concerns by keeping shape definition, sizing, and coloring in distinct sections of your CSS, making it easier to swap out individual aspects. Group browser-specific styles by placing WebKit and Firefox selectors together with clear comments explaining why both are needed. Comment complex gradients to document what each gradient layer contributes to the final shape--this saves tremendous time when you or a teammate needs to make adjustments months later.

Example Organized Structure

.rating {
 /* Configuration */
 --size: 48px;
 --color-selected: gold;
 --color-unselected: #ddd;
 --step: 1;
 
 /* Base styles */
 height: var(--size);
 width: calc(5 * var(--size));
 appearance: none;
 
 /* Mask */
 --star-mask: /* complex gradient */;
 mask-image: var(--star-mask);
 mask-size: var(--size);
}

Testing Checklist

Before deploying, verify your implementation across multiple dimensions. Test on all major browsers including Chrome, Firefox, Safari, and Edge to ensure consistent appearance and behavior. Verify keyboard navigation works correctly with arrow keys, Page Up/Down, and Home/End keys. Check screen reader announcements using tools like NVDA, VoiceOver, or browser accessibility inspectors. Test touch interaction on actual mobile devices, not just browser emulators. Verify color contrast meets WCAG guidelines using automated tools or manual testing. Test with reduced motion preferences enabled to ensure users who are sensitive to motion have a good experience.

Common Pitfalls to Avoid

Learn from common mistakes that developers encounter when implementing CSS-only components. Forgetting browser-specific selectors leads to inconsistent appearance across browsers--always include both WebKit and Firefox pseudo-elements. Incorrect padding calculations result in misaligned stars--remember that padding should be half the step value times star size. Missing box-sizing causes layout shifts and unexpected sizing--ensure box-sizing: border-box is properly applied. Focus outline removal without replacement creates accessibility problems--add custom focus indicators using :focus-visible to maintain keyboard usability.

Maintenance Considerations

Plan for long-term success by documenting your implementation and keeping dependencies minimal. Document custom gradient calculations so future developers understand how the star shape is constructed. Keep CSS custom properties documented for easy customization by designers or developers who need to theme the component. Test after browser updates that may change range input behavior, as browsers occasionally modify how these elements work under the hood. For teams looking to implement modern CSS techniques at scale, establishing clear documentation standards and component libraries pays dividends over time.

Conclusion

The CSS-only star rating component demonstrates the remarkable power of modern CSS techniques. By leveraging native browser functionality--specifically the range input element--we can create fully interactive, accessible, and performant components without JavaScript dependency for the core visual rendering.

Key Takeaways

Several fundamental principles emerge from this technique that apply broadly to web development. The range input is powerful--it provides built-in functionality that we'd otherwise spend significant time coding from scratch, including keyboard navigation, touch support, and value validation. CSS masks create shapes--complex geometries like stars can be created purely with gradient masks, opening creative possibilities beyond traditional rectangular elements. Border-image fills visually--the fill keyword enables gradient-based coloring that follows user interaction, creating dynamic feedback without JavaScript visual logic. Accessibility comes first--native keyboard support and proper ARIA attributes ensure universal usability, and the techniques shown here enhance rather than compromise accessibility. The technique is extensible--the same approach works for hearts, volume controls, progress indicators, and custom brand shapes with minimal modification.

When to Use This Approach

CSS-only components are ideal when maximum performance is critical, bundle size is a concern, graceful degradation to basic functionality is acceptable, and the component's interactive behavior matches the semantics of native elements like range inputs. They're particularly valuable for high-traffic pages where even small JavaScript savings compound into meaningful improvements. For complex interactions that don't map to native element semantics, JavaScript remains necessary--but many UI patterns can leverage these CSS-first approaches.

What's Next (Part 2)

In Part 2 of this series, we'll explore advanced variations that push the technique further. Animated transitions between states add polish and delight when ratings change. Multi-dimensional rating systems let users rate products across multiple criteria simultaneously. Integration with form validation ensures ratings are properly required and validated before submission. Custom theming and styling options provide brand-aligned visual treatments while maintaining performance benefits.

Start Building Today

The techniques covered here are production-ready and used across the web. Start implementing CSS-only components in your projects and experience the performance benefits firsthand. Your users will thank you for faster page loads, smoother interactions, and components that work reliably across all their devices and browsers.

Frequently Asked Questions

Ready to Build High-Performance Web Interfaces?

Our team specializes in modern web development techniques that prioritize performance, accessibility, and user experience.