On this page
Star Rating
Last reviewed 22 Sept 2026
Problem
Build a StarRating component. It shows max stars; clicking a star sets the rating; hovering previews it; it can be read-only:
const [rating, setRating] = useState(3);<StarRating value={rating} onChange={setRating} max={5} /><StarRating value={4} readOnly />Clarifying questions
- Controlled (
value+onChange) or uncontrolled? Controlled — the parent owns the rating. - Half stars? Follow-up.
- Can the user clear the rating (click the current star again)? Agree on it — here, yes.
- Keyboard and screen-reader support? Yes: arrow keys, and announce “3 of 5 stars”.
Approach
Render max buttons. A local hover value (or null) previews the rating; the displayed rating is hover ?? value. Keyboard handling is a pure function mapping the current value and a key to the next value, so it can be tested without rendering. The container uses role="radiogroup", each star role="radio" with aria-checked, and roving tabIndex so Tab enters the group once.
Step-by-step build
Step 1 — render and click
function StarRating({ value, onChange, max = 5 }) { return ( <div> {Array.from({ length: max }, (_, i) => ( <button key={i} onClick={() => onChange(i + 1)}>{i < value ? '★' : '☆'}</button> ))} </div> );}Step 2 — hover preview
const [hover, setHover] = useState(null);const shown = hover ?? value;// onMouseEnter={() => setHover(i + 1)} onMouseLeave on the container → setHover(null)Step 3 — keyboard
function nextRating(value, key, max) { switch (key) { case 'ArrowRight': case 'ArrowUp': return Math.min(max, value + 1); case 'ArrowLeft': case 'ArrowDown': return Math.max(1, value - 1); case 'Home': return 1; case 'End': return max; default: return value; }}Step 4 — ARIA and read-only
role="radiogroup" with an aria-label; each star role="radio", aria-checked={value === i + 1}, aria-label="3 stars". In read-only mode render plain elements with role="img" and aria-label="4 out of 5 stars".
Final code
import { useState } from 'react';
export function nextRating(value, key, max) { switch (key) { case 'ArrowRight': case 'ArrowUp': return Math.min(max, value + 1); case 'ArrowLeft': case 'ArrowDown': return Math.max(1, value - 1); case 'Home': return 1; case 'End': return max; default: return value; }}
// Clicking the current rating clears it (0); any other star sets it.export function ratingAfterClick(value, star) { return star === value ? 0 : star;}
export function StarRating({ value = 0, onChange, max = 5, readOnly = false, label = 'Rating' }) { const [hover, setHover] = useState(null); const shown = hover ?? value;
if (readOnly) { return ( <span role="img" aria-label={`${value} out of ${max} stars`}> {Array.from({ length: max }, (_, i) => (i < value ? '★' : '☆')).join('')} </span> ); }
const focusIndex = Math.max(0, value - 1); // roving tabindex: only one star is tabbable return ( <div role="radiogroup" aria-label={label} onMouseLeave={() => setHover(null)} onKeyDown={(e) => { const next = nextRating(value, e.key, max); if (next !== value) { e.preventDefault(); onChange(next); e.currentTarget.querySelectorAll('[role="radio"]')[next - 1]?.focus(); } }} > {Array.from({ length: max }, (_, i) => { const star = i + 1; return ( <button key={star} type="button" role="radio" aria-checked={value === star} aria-label={`${star} star${star > 1 ? 's' : ''}`} tabIndex={i === focusIndex ? 0 : -1} onMouseEnter={() => setHover(star)} onClick={() => onChange(ratingAfterClick(value, star))} className={star <= shown ? 'star filled' : 'star'} > {star <= shown ? '★' : '☆'} </button> ); })} </div> );}Edge cases
value = 0(no rating): the first star is the tab stop.- Hover preview never changes the real value; leaving the group restores it.
- Arrow keys stop at 1 and
maxrather than wrapping. - Read-only mode has no buttons, so it is not focusable and not clickable.
- Clicking the current star clears the rating — make it obvious in the UI or drop the behaviour.
Follow-ups
- Half stars: use the pointer’s x position inside a star (
offsetX < width / 2) and render a half-filled icon. - Uncontrolled mode: accept
defaultValueand keep internal state whenvalueis not passed. - Why buttons, not divs? Buttons are focusable and respond to Enter/Space for free.
- Native alternative: a group of
<input type="radio">styled as stars — accessibility built in.
Common mistakes
- Every star is a Tab stop, so keyboard users must press Tab five times to get past it.
- Hover state that overwrites the actual value.
- Stars as
<span onClick>— no keyboard access, no semantics. - Fixed at five stars instead of a
maxprop.
Related
- Next: nested file tree.