javascript 43 lines · 8 steps

Building an accessible star rating in React

A controlled star-rating widget that previews on hover while keeping the committed value and screen-reader semantics distinct.

Explained by highlit
1import { useState } from 'react';
2 
3function StarRating({ value = 0, max = 5, onChange, size = 24 }) {
4 const [hovered, setHovered] = useState(null);
5 const active = hovered ?? value;
6 
7 return (
8 <div role="radiogroup" aria-label="Rating" style={{ display: 'inline-flex', gap: 4 }}>
9 {Array.from({ length: max }, (_, i) => {
10 const rating = i + 1;
11 const filled = rating <= active;
12 return (
13 <button
14 key={rating}
15 type="button"
16 role="radio"
17 aria-checked={rating === value}
18 aria-label={`${rating} star${rating > 1 ? 's' : ''}`}
19 onClick={() => onChange?.(rating === value ? 0 : rating)}
20 onMouseEnter={() => setHovered(rating)}
21 onMouseLeave={() => setHovered(null)}
22 onFocus={() => setHovered(rating)}
23 onBlur={() => setHovered(null)}
24 style={{
25 background: 'none',
26 border: 'none',
27 padding: 0,
28 cursor: 'pointer',
29 fontSize: size,
30 lineHeight: 1,
31 color: filled ? '#f5a623' : '#d0d0d0',
32 transition: 'color 120ms ease',
33 }}
34 >
35 {filled ? '\u2605' : '\u2606'}
36 </button>
37 );
38 })}
39 </div>
40 );
41}
42 
43export default StarRating;
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Separating transient hover state from the committed value lets you preview without mutating the source of truth.
  2. 2ARIA roles like radiogroup and radio turn a row of buttons into a control assistive tech understands.
  3. 3The nullish coalescing operator cleanly falls back to a default only when hover is genuinely absent.

Related explainers

javascript
import { Suspense } from 'react';
import { searchProducts } from '@/lib/products';
import SearchInput from './search-input';
 

Streaming search results in a Next.js Server Component

server-components suspense streaming
Intermediate 8 steps
javascript
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
import { getPostBySlug, getRelatedPosts } from '@/lib/posts'
import { RelatedPosts } from '@/components/related-posts'

Building a dynamic blog post page in Next.js

server-components dynamic-routing metadata
Intermediate 8 steps
javascript
const express = require('express');
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
 
const router = express.Router();

Building an API gateway proxy in Express

reverse-proxy middleware api-gateway
Intermediate 6 steps
typescript
import { Injectable, signal, computed, effect, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';
 
export type Theme = 'light' | 'dark';

A signal-based theme service in Angular

signals reactivity dependency-injection
Intermediate 7 steps
javascript
import { useCallback, useRef, useState } from 'react';
 
export function ColorPicker({ initialColor = '#3b82f6', onCommit }) {
  const [committed, setCommitted] = useState(initialColor);

A validated color picker in React

uncontrolled-inputs refs validation
Intermediate 7 steps
javascript
import { useEffect, useRef } from 'react';
 
export function useRefetchOnFocus(refetch, { staleTime = 30_000 } = {}) {
  const lastFetchedAt = useRef(Date.now());

A React hook that refetches on tab focus

custom-hooks refs event-listeners
Intermediate 6 steps

Share this explainer

Here's the card — post it anywhere.

Building an accessible star rating in React — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code