Build a Star Rating
State = the rating value; a hover state for preview. Render N stars, fill based on hover ?? value. Support half-stars, read-only mode, keyboard (arrows), and accessibility (radiogroup or a slider role). The trap: use real buttons/inputs, not divs, and make hover preview not clobber the committed value.
A star rating is small but tests hover-vs-committed state separation, accessibility, and using semantic elements.
Core implementation
function StarRating({ value = 0, onChange, max = 5, readOnly = false }) {
const [hover, setHover] = useState(null);
const display = hover ?? value; // hover previews, value is committed
return (
<div role="radiogroup" aria-label="Rating">
{Array.from({ length: max }, (_, i) => {
const starValue = i + 1;
return (
<button
key={starValue}
type="button"
role="radio"
aria-checked={starValue === value}
aria-label={`${starValue} star${starValue > 1 ? "s" : ""}`}
disabled={readOnly}
onClick={() => onChange?.(starValue)}
onMouseEnter={() => !readOnly && setHover(starValue)}
onMouseLeave={() => !readOnly && setHover(null)}
>
{starValue <= display ? "★" : "☆"}
</button>
);
})}
</div>
);
}The key design points
- Hover ≠ value. Keep a separate
hoverstate;display = hover ?? value. Hovering previews a rating; clicking commits it. Conflating them means the rating changes just by mousing over — a classic bug. - Real interactive elements —
<button>s (or radio<input>s), not<div onClick>. You get keyboard, focus, and semantics for free. - Accessibility — it's a single choice from N:
role="radiogroup"+role="radio"witharia-checked, or arole="slider"witharia-valuenow/min/max. Each star labeled ("3 stars"). - Keyboard support — arrow keys to increase/decrease the rating, Enter/Space to commit.
- Read-only mode — for displaying ratings (no hover, no click, not focusable as inputs).
- Half-stars — if required: compute fill per star from a fractional value (e.g. clip-path or two overlaid layers); hover then snaps to halves based on cursor x-position within the star.
- Controlled vs uncontrolled — support
value/onChangeand adefaultValue.
The framing
"State is the committed value plus a separate hover state — display = hover ?? value — so hovering previews and clicking commits; merging them means the rating changes on mouseover, which is the classic bug. I render real <button>s, not divs, so keyboard and focus come free, and wire it up as a radiogroup of radios with aria-checked and per-star labels, plus arrow-key support. A readOnly mode handles the display case. Half-stars, if needed, are a fractional fill per star with hover snapping to halves by cursor position."
Follow-up questions
- •Why keep hover state separate from the committed value?
- •Why use buttons instead of divs?
- •How do you make a star rating accessible?
- •How would you implement half-star ratings?
Common mistakes
- •Conflating hover and value — rating changes on mouseover.
- •divs with onClick — no keyboard/focus/semantics.
- •No ARIA roles, no keyboard support.
- •No read-only mode for displaying ratings.
- •Forgetting to reset hover on mouse leave.
Performance considerations
- •Trivial — N is small. The only concern is not re-rendering excessively on hover; local hover state keeps it contained.
Edge cases
- •Read-only display vs interactive input.
- •Half-star / fractional values.
- •Clearing a rating back to zero.
- •Keyboard-only users.
- •RTL layouts.
Real-world examples
- •Product reviews, feedback forms, app store ratings.
- •Read-only star displays on listing cards.