UI build: accessible star rating
Five radios, one name, one stop; arrows commit, the pointer only previews.
Predict first
Before you read: predict
ExamHall's post-exam survey renders <SurveyRating value={rating} onChange={setRating} />. The visually-hidden class clips an element to one pixel; it stays in the page.
A candidate has chosen 5 stars, and focus is on that star's radio. The candidate presses Right once.
Which radio has focus, and what is the rating?
function SurveyRating({ value, onChange }) { return ( <fieldset> <legend>Rate the proctoring</legend> {[1, 2, 3, 4, 5].map((n) => ( <label key={n}> <input type="radio" name="proctoring" value={n} className="visually-hidden" checked={value === n} onChange={() => onChange(n)} /> <span aria-hidden="true" className={n <= value ? 'star on' : 'star'}>★</span> <span className="visually-hidden">{n} of 5 stars</span> </label> ))} </fieldset> );}The 30-second version
A star rating is a radio group drawn as stars. One name makes the five radios one Tab stop; arrows move and check, wrapping at the ends. Hide each input with a clip, never display: none, label each star in words, and keep hover in its own state so pointing previews and commits nothing.
Five radios, one name, one stop; arrows commit, the pointer only previews.
How it works
Step 1 of 13
<input type="radio"> inside a fieldset whose legend names the question (ui-semantics-aria step 5).Analogy: A hearing aid's programme button (audiology)
A wearer changes the listening programme on a hearing aid.
| At the hearing aid | In the build |
|---|---|
| her finger finds a single button that steps through every programme | one name, one Tab stop (step 2) |
| each press switches the aid at once, with no confirm | an arrow checks the next radio (step 4) |
| after the last programme the next press brings back the first | the group wraps (step 4) |
| the aid says "Restaurant", "Music" aloud, not a pattern of beeps | a label in words per star (step 7) |
| at the fitting, the audiologist plays each programme before any is saved | hover previews (step 9) |
In real terms: five radios sharing a name, a clip instead of display: none, a sentence per star, and a hover state the mouse owns.
Breaks down when: the button only steps forward; Left and Up step back.
Contrasting case: five push buttons on a car radio, each its own reach. Pressing one releases the others, but a finger travels to each in turn: five stops, where the rating needs one.
Examples, run for real
4.1 ExamHall EXH-4650 and MR !4950: the kata's reference in the survey
EXH-4650 is the survey's first rating: five ★ spans with click handlers. MR !4950 replaces it with the kata's reference.
4.1 before (EXH-4650, star spans with click handlers): Tab from Comments: focus on Submit · stars a keyboard can reach: 0 of 5 · radios in the tree: 0
4.1 MR !4950: Tab from Comments: focus on 4 of 5 stars (checked) · Tab again: focus on Submit
4.1 snapshot - group "Rate the proctoring": radios "1 of 5 stars" … "5 of 5 stars" · axe violations 0
The spans are no control at all: Tab passes them and the tree holds no radio. The reference is one stop entered on the checked star, and each radio is named in words (steps 1–3, 7).
4.2 Ticketing: a venue rating built from role="radio"
A box office rates its venues with five divs carrying role="radio", aria-checked, a roving tabIndex and a Down handler.
4.2 Tab from Comments: focus on 3 of 5 stars · Down: focus on 4 of 5 stars, aria-checked="true" on it alone
4.2 snapshot - radio "4 of 5 stars" [checked] · axe violations 0
The custom group behaves like the native one only because its code moves aria-checked and the tabIndex itself (step 12).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A box office asks fans to rate their seat after a show. The form has a Comments box, then <SeatRating value={rating} onChange={setRating} />, then a Send button. The visually-hidden class clips an element to one pixel; it stays in the page.
The fan has chosen 2 stars, and focus is in the Comments box.
How many presses of Tab move focus to the Send button?
function SeatRating({ value, onChange }) { return ( <fieldset> <legend>Rate your seat</legend> {[1, 2, 3, 4, 5].map((n) => ( <label key={n}> <input type="radio" name={`seat-${n}`} value={n} className="visually-hidden" checked={value === n} onChange={() => onChange(n)} /> <span aria-hidden="true" className={n <= value ? 'star on' : 'star'}>★</span> <span className="visually-hidden">{n} of 5 stars</span> </label> ))} </fieldset> );}Question 2 of 8
A chat app asks for a rating after each voice call. The page renders <CallRating value={rating} onChange={setRating} /> and a Send button below it.
The user has chosen 2 stars. Without clicking, the user moves the pointer across stars 3 and 4 to star 5, then down onto the Send button.
How many stars are drawn filled now?
function CallRating({ value, onChange }) { const [hover, setHover] = useState(null); const shown = hover ?? value; return ( <fieldset> <legend>Rate this call</legend> {[1, 2, 3, 4, 5].map((n) => ( <label key={n} onMouseEnter={() => setHover(n)}> <input type="radio" name="call" value={n} className="visually-hidden" checked={value === n} onChange={() => onChange(n)} /> <span aria-hidden="true" className={n <= shown ? 'star on' : 'star'}>★</span> <span className="visually-hidden">{n} of 5 stars</span> </label> ))} </fieldset> );}Question 3 of 8
A streaming app asks viewers to rate a film. The visually-hidden class clips an element to one pixel; it stays in the page.
Before release, a tester reads the five radios' names in Chromium's accessibility tree.
What names does it give them?
function FilmRating({ value, onChange }) { return ( <fieldset> <legend>Rate this film</legend> {[1, 2, 3, 4, 5].map((n) => ( <label key={n}> <input type="radio" name="film" value={n} className="visually-hidden" checked={value === n} onChange={() => onChange(n)} /> <span aria-hidden="true" className={n <= value ? 'star on' : 'star'}>★</span> </label> ))} </fieldset> );}Question 4 of 8
A checkout page ends with an optional rating of the payment step: a Card number box, then <CheckoutRating value={rating} onChange={setRating} />, then a Pay button. A developer wrote an sr-only class to hide each radio and its words, so that only the label's star shows:
.sr-only { display: none; }
.star { font-size: 2rem; cursor: pointer; }
No star is chosen yet. A keyboard user in the Card number box presses Tab once.
Which element has focus?
function CheckoutRating({ value, onChange }) { return ( <fieldset> <legend>Rate the payment step</legend> {[1, 2, 3, 4, 5].map((n) => ( <label key={n}> <input type="radio" name="checkout" value={n} className="sr-only" checked={value === n} onChange={() => onChange(n)} /> <span aria-hidden="true" className={n <= value ? 'star on' : 'star'}>★</span> <span className="sr-only">{n} of 5 stars</span> </label> ))} </fieldset> );}Question 5 of 8
the interviewer says: "Build the post-exam survey's rating. SurveyRating({ value, onChange }) shows five stars under the legend Rate the proctoring; value is a number from 1 to 5, or null before the candidate chooses. Draw each star as <span aria-hidden="true" className="star">★</span>, with the class star on when it is filled. A pointer over a star previews it; a click or a key chooses." You have one coding round, a plain editor and React 19.
Before you type: say your test list out loud.
Name at least eight tests, each with its keys or pointer moves and what it expects.
Question 6 of 8
the interviewer says: "Build the post-exam survey's rating. SurveyRating({ value, onChange }) shows five stars under the legend Rate the proctoring; value is a number from 1 to 5, or null before the candidate chooses. Draw each star as <span aria-hidden="true" className="star">★</span>, with the class star on when it is filled. A pointer over a star previews it; a click or a key chooses." You have one coding round, a plain editor and React 19.
Now write it, from a blank file that defines SurveyRating.
Question 7 of 8
A box office's app asks fans to rate the venue.
The rating is 2. A fan rests the pointer on star 4 and leaves it there, while focus is on the 2 of 5 stars radio. The fan presses Left once.
How many stars are drawn filled, and which radio is checked?
function VenueRating({ value, onChange }) { const [hover, setHover] = useState(null); const shown = hover ?? value; return ( <fieldset onMouseLeave={() => setHover(null)}> <legend>Rate the venue</legend> {[1, 2, 3, 4, 5].map((n) => ( <label key={n} onMouseEnter={() => setHover(n)}> <input type="radio" name="venue" value={n} className="visually-hidden" checked={value === n} onChange={() => onChange(n)} /> <span aria-hidden="true" className={n <= shown ? 'star on' : 'star'}>★</span> <span className="visually-hidden">{n} of 5 stars</span> </label> ))} </fieldset> );}Question 8 of 8
A team chat asks members to rate the weekly meeting. MeetingRating draws five radios named meeting, as the opening question's rating does, and calls onChange(n) from each radio's onChange. The page saves each choice:
Focus is on the checked 4 of 5 stars radio. A member presses Right twice, then Space, then Tab. The server answers every request at once.
How many rating requests are sent?
function MeetingSurvey() { const [rating, setRating] = useState(4); function choose(n) { setRating(n); fetch('/api/meeting-rating', { method: 'POST', body: String(n) }); } return ( <form> <MeetingRating value={rating} onChange={choose} /> <button type="button">Done</button> </form> );}Go deeper
Pro tips
- Build on real
<input type="radio">elements visually replaced by stars. When: the interviewer asks for the accessible structure: buttons, radios or a slider (steps 1, 6). Verify (RAN,tips.mjs): the kata's reference inside a<form>, with no key handler of its own:
(The curriculum row's tip, [DOC]: keyboard behaviour and form submission then come from the platform, APG radio pattern.)tip 1 reference.jsx key handlers: 0 · from 2 of 5 stars, Right, Right: 4 of 5 stars checked · FormData: proctoring=4 - Run your build against the test list before you call it done:
KATA_IMPL=./my-rating.jsx node --test kata/kata.test.mjs. When: practising the kata at home, against the cases the reference passes (step 13). Verify (RAN,tips.mjs):tip 2 KATA_IMPL=./kata/reference.jsx: exit 0 · tests 12 · pass 12 · fail 0 tip 2 KATA_IMPL=<temp>/no-leave.jsx (no onMouseLeave on the group): exit 1 · tests 12 · pass 11 · fail 1 · red: K10
Source
Guides change, so their claims are labelled as of 2026-10-16; every behaviour this lesson grades is executed in Chromium.
- W3C WAI ARIA Authoring Practices Guide, Radio Group Pattern (the curriculum row's first source), https://www.w3.org/WAI/ARIA/apg/patterns/radio/ (
apg-radio.txt): "If a radio button is checked, focus is set on the checked button."; "If none of the radio buttons are checked, focus is set on the first radio button in the group."; "Right Arrow and Down Arrow: move focus to the next radio button in the group, uncheck the previously focused button, and check the newly focused button."; "If focus is on the last button, focus moves to the first button."; "In some browsers, if none of the radio buttons are selected, moving focus into the radio group with Shift + Tab will place focus on the last radio button instead of the first radio button." (steps 3, 4). As of 2026-10-16. - W3C WAI ARIA Authoring Practices Guide, Rating Radio Group Example, https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/ (
apg-radio-rating.txt): "The rating is indicated by the number of stars selected by the user."; "Defines an accessible name that identifies the number of stars associated with the radio button" (steps 7, 12). As of 2026-10-16. - MDN,
<input type="radio">, https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/radio (mdn-radio.txt): "A radio group is defined by giving each of radio buttons in the group the same name." (step 2). As of 2026-10-16. - WHATWG HTML Living Standard, the input element, Radio Button state, https://html.spec.whatwg.org/multipage/input.html (
whatwg-input.txt): "They both have a name attribute, their name attributes are not empty, and the value of a's name attribute equals the value of b's name attribute." (step 2). As of 2026-10-16. - Front End Interview Handbook, Build front end user interfaces (the row's second source), https://www.frontendinterviewhandbook.com/coding/build-front-end-user-interfaces (
feih-ui.txt): lists "Star Rating (Free)" among the components to practise, and asks "Are there any potential UX/accessibility (a11y) issues with your components?" (the kata's frame). As of 2026-10-16.
APG describes the keys; the Chromium behaviour this lesson teaches (wrap, a check per arrow, Shift+Tab entering on the last radio, display: none leaving the Tab order, the names in Chromium's accessibility tree, the focus ring on the star) is executed, not cited.
Related lessons
Checked against the tools and docs as of 2026-10-16.