# Star ratings that keyboards and screen readers skip

Source: https://easeweb.dev/learn/star-ratings
Topics: Custom inputs > Star ratings can be chosen with arrow keys and say how many stars are set (WCAG 2.1.1, 4.1.2, 1.3.1)
The fix: Build the stars as one radio group, named by its question, with each star named by its value. Either keep real radio buttons over the drawn stars, hidden only from sight, and fill the stars from :checked; or keep the spans and make them an ARIA radio group with aria-checked, a roving tabindex and the arrow keys; or use the radio buttons and also write the chosen rating out in words beside the stars.
Test with: keyboard, screen reader, mouse, forced colors, touch
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/ https://www.w3.org/WAI/ARIA/apg/patterns/radio/ https://developer.mozilla.org/en-US/docs/Web/CSS/:has https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/fieldset

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's review form is drawn with a row of five large stars under "Your rating": grey outlines that fill amber up to the one you click. Stars are what every shopper already knows how to read, and a row of them asks for an opinion more lightly than a dropdown or a row of numbers would. The developer builds what was drawn, in one of two common ways. Either each star is a `span` with a click handler that fills it and the stars before it, or each star is the label of a radio button, with the radio buttons hidden with `display: none` and the stars filled from the one that is checked. With a mouse, both look the same and work the same: click the fourth star, and four stars fill.

Put the mouse aside and press Tab from the question. Focus goes straight to Post review: no star is ever reached, and no key fills one. A screen reader reads "Your rating", then "Post review, button". With the spans, the stars are silent. With the hidden radio buttons, it reads the words hidden in the labels, "1 star" to "5 stars", as five lines of plain text, with no radio buttons and nothing to say which one is chosen. Click the fourth star with a mouse and listen again: what is read hasn't changed. The rating exists only as four filled stars.

## Why it fails

The stars are drawn as controls but aren't built as any. A `span` is not focusable and has no role, and a click handler adds neither. A radio button with `display: none` is taken out of the page entirely, out of the Tab order and out of the accessibility tree, while a click on its label still checks it, so the mouse path keeps working and hides the problem from anyone testing with a mouse.

That fails three criteria. 2.1.1 Keyboard: a click chooses a rating, but no key can, because nothing is left to focus. 4.1.2 Name, Role, Value: what people see is five choices of one value with one chosen, but software finds no radio button, no name such as "4 stars", and no checked state, so a screen reader can't say what the stars are or which is set. 1.3.1 Info and Relationships: the drawing shows three things at once: that "Your rating" is the question the stars answer, that the five stars belong together as one choice, and how many are chosen. In the code, with the spans, the question is a paragraph tied to nothing, the stars are separate spans, and the choice is a class on four of them. With the hidden radio buttons, the fieldset still groups the stars under their question, but the radio buttons that make them one choice, and say which is chosen, have been removed. Either way, the choice exists only on screen. This is failure F15 in WCAG's own list: a custom control that doesn't expose itself through the accessibility API.

## Who is affected

Ratings come at the end of something: a review after a purchase, "How did we do?" after a support chat, a score for a course or a recipe. When only a mouse can set one, the review goes without its rating, and the ratings a shop collects come only from people who can click or tap.

## What automation and AI miss

Most of what is wrong is what's missing. axe tests what is rendered, so it skips a radio button with `display: none`, and an empty `span` with a background star has no role to check. A scan of what is there finds nothing wrong. EaseWeb's audit finds both forms: a row of stars in an element whose class or id calls it a rating or stars, each with a pointer cursor and none reached by Tab, as `ew:rating-star-unreachable`, and a radio button hidden while its label is shown, including star labels with no words, as `ew:hidden-native-input`. When a rebuild gives the stars `role="radio"` without a name, axe reports `aria-toggle-field-name`. When asked to fix it, AI coding tools can add `tabindex="0"` to each span: Tab then stops five times, but no key chooses, and nothing announces a radio button or which one is chosen. Another patch adds `aria-label="4 stars"` to each span, which a span can't carry, and still leaves the keyboard out. Put the mouse aside and try to rate.

## Before: five spans with onclick

```html
<form action="/reviews" method="post">
  <p class="question">Your rating</p>
  <div class="stars">
    <span class="star" onclick="rate(1)"></span>
    <span class="star" onclick="rate(2)"></span>
    <span class="star" onclick="rate(3)"></span>
    <span class="star" onclick="rate(4)"></span>
    <span class="star" onclick="rate(5)"></span>
  </div>
  <input type="hidden" name="rating">
  …
  <button type="submit">Post review</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
}
/* An empty star: a grey outline, #6b7280 */
.star {
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
/* A filled star: solid amber, #d97706 */
.star.on {
  background-image: url("star-filled.svg");
}
```

```js
function rate(value) {
  document.querySelector('input[name="rating"]').value = value;
  document.querySelectorAll('.star').forEach((star, index) => {
    star.classList.toggle('on', index < value);
  });
}
```

The spans take a click and nothing else. They aren't in the Tab order, they have no role, name or state, and the rating lives in a class and a hidden form value that no assistive technology reads.

## Before: radio buttons hidden with display: none

```html
<form action="/reviews" method="post">
  <fieldset class="stars">
    <legend class="question">Your rating</legend>
    <label class="star">
      <input type="radio" name="rating" value="1">
      <span class="visually-hidden">1 star</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="2">
      <span class="visually-hidden">2 stars</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="3">
      <span class="visually-hidden">3 stars</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="4">
      <span class="visually-hidden">4 stars</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="5">
      <span class="visually-hidden">5 stars</span>
    </label>
  </fieldset>
  …
  <button type="submit">Post review</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
  margin: 0;
  border: 0;
  padding: 0;
}
/* An empty star: a grey outline, #6b7280 */
.star {
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
.star input {
  display: none;
}
/* Every star up to the checked one is filled: solid amber, #d97706 */
.stars:has(:checked) .star {
  background-image: url("star-filled.svg");
}
.star:has(:checked) ~ .star {
  background-image: url("star.svg");
}
.visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

The markup is almost right: the question as the group's legend, real radio buttons, one name, a word for each star. `display: none` undoes it. A click on a label still checks its radio button, so the stars fill, but the radio buttons are out of the Tab order and the accessibility tree, and the words in the labels are left as plain text, with no checked state.

## After: radio buttons over the drawn stars

```html
<form action="/reviews" method="post">
  <fieldset class="stars">
    <legend class="question">Your rating</legend>
    <label class="star">
      <input type="radio" name="rating" value="1">
      <span class="visually-hidden">1 star</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="2">
      <span class="visually-hidden">2 stars</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="3">
      <span class="visually-hidden">3 stars</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="4">
      <span class="visually-hidden">4 stars</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="5">
      <span class="visually-hidden">5 stars</span>
    </label>
  </fieldset>
  …
  <button type="submit">Post review</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
  margin: 0;
  border: 0;
  padding: 0;
}
/* An empty star: a grey outline, #6b7280 */
.star {
  position: relative;
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
/* Hidden from sight only: each radio button covers its star */
.star input {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
/* Every star up to the checked one is filled: solid amber, #d97706 */
.stars:has(:checked) .star {
  background-image: url("star-filled.svg");
}
.star:has(:checked) ~ .star {
  background-image: url("star.svg");
}
/* The ring, from the radio button's own focus */
.star:has(:focus-visible) {
  outline: 2px solid #1a1a1a;
  outline-offset: 2px;
  border-radius: 0.25rem;
}
.visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

The design is kept exactly as drawn: the same stars, the same fill, nothing new on screen but a focus ring. The radio buttons are made transparent instead of removed, each one over its own star, so they stay in the Tab order and the accessibility tree, and a touch on a star lands on its radio button. The browser does the rest. Tab stops on the rating once, the arrow keys move the choice one star at a time, and a screen reader announces the question, "Your rating", then something like "4 stars, radio button, checked, 4 of 5". The question is the group's `legend`, and the stars fill from the checked radio button itself, so what is drawn can't drift from what is announced. The radio buttons sit in source order, 1 to 5, and `:has()` fills the stars before the checked one, so nothing is reversed to make the fill work.

## After: the spans kept, as an ARIA radio group

```html
<form action="/reviews" method="post">
  <p class="question" id="rating-question">Your rating</p>
  <div class="stars" role="radiogroup" aria-labelledby="rating-question">
    <span class="star" role="radio" aria-checked="false" aria-label="1 star" tabindex="0" onclick="rate(1)"></span>
    <span class="star" role="radio" aria-checked="false" aria-label="2 stars" tabindex="-1" onclick="rate(2)"></span>
    <span class="star" role="radio" aria-checked="false" aria-label="3 stars" tabindex="-1" onclick="rate(3)"></span>
    <span class="star" role="radio" aria-checked="false" aria-label="4 stars" tabindex="-1" onclick="rate(4)"></span>
    <span class="star" role="radio" aria-checked="false" aria-label="5 stars" tabindex="-1" onclick="rate(5)"></span>
  </div>
  <input type="hidden" name="rating">
  …
  <button type="submit">Post review</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
}
/* An empty star: a grey outline, #6b7280 */
.star {
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
/* Every star up to the checked one is filled: solid amber, #d97706 */
.stars:has([aria-checked="true"]) .star {
  background-image: url("star-filled.svg");
}
.star[aria-checked="true"] ~ .star {
  background-image: url("star.svg");
}
.star:focus-visible {
  outline: 2px solid #1a1a1a;
  outline-offset: 2px;
  border-radius: 0.25rem;
}
```

```js
const stars = [...document.querySelectorAll('.star')];

function rate(value) {
  document.querySelector('input[name="rating"]').value = value;
  stars.forEach((star, index) => {
    const chosen = index === value - 1;
    star.setAttribute('aria-checked', String(chosen));
    star.tabIndex = chosen ? 0 : -1;
  });
  stars[value - 1].focus();
}

// The keys of a radio group: the arrows move the choice, wrapping at the ends; Space chooses.
const STEP = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
stars.forEach((star, index) => {
  star.addEventListener('keydown', (event) => {
    if (event.key === ' ') rate(index + 1);
    else if (event.key in STEP) rate(((index + STEP[event.key] + 5) % 5) + 1);
    else return;
    event.preventDefault();
  });
});
```

This keeps the spans and the look, and adds by hand what radio buttons would have brought: the role and checked state of each star (`role="radio"` and `aria-checked`), a name for each and one for the group, one Tab stop that moves to the chosen star (a roving `tabindex`), and the arrow keys. The fill now comes from `aria-checked` instead of a class, so the stars can't show a rating that isn't announced. It is the pattern of the W3C's own rating example, and it is right when the stars have to stay elements a script draws, such as SVG icons in a component library. Every piece is needed: leave out the keys, and Tab reaches one star that no key changes; leave out `aria-checked`, and a screen reader hears five radio buttons with none chosen.

## After: radio buttons, with the rating in words

```html
<form action="/reviews" method="post">
  <fieldset class="stars">
    <legend class="question">Your rating</legend>
    <label class="star">
      <input type="radio" name="rating" value="1">
      <span class="visually-hidden">1 star</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="2">
      <span class="visually-hidden">2 stars</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="3">
      <span class="visually-hidden">3 stars</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="4">
      <span class="visually-hidden">4 stars</span>
    </label>
    <label class="star">
      <input type="radio" name="rating" value="5">
      <span class="visually-hidden">5 stars</span>
    </label>
    <span class="score">Not rated yet</span>
  </fieldset>
  …
  <button type="submit">Post review</button>
</form>
```

```css
.question {
  margin: 0 0 0.5rem;
  padding: 0;
  font-weight: bold;
}
.stars {
  display: flex;
  gap: 0.25rem;
  margin: 0;
  border: 0;
  padding: 0;
}
/* An empty star: a grey outline, #6b7280 */
.star {
  position: relative;
  inline-size: 2rem;
  block-size: 2rem;
  background: url("star.svg") center / contain no-repeat;
  cursor: pointer;
}
/* Hidden from sight only: each radio button covers its star */
.star input {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
/* Every star up to the checked one is filled: solid amber, #d97706 */
.stars:has(:checked) .star {
  background-image: url("star-filled.svg");
}
.star:has(:checked) ~ .star {
  background-image: url("star.svg");
}
/* The ring, from the radio button's own focus */
.star:has(:focus-visible) {
  outline: 2px solid #1a1a1a;
  outline-offset: 2px;
  border-radius: 0.25rem;
}
/* The chosen rating in words, beside the stars: #4b5563, 7.56:1 */
.score {
  align-self: center;
  margin-inline-start: 0.75rem;
  color: #4b5563;
}
.visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

```js
const WORDS = ['Poor', 'Fair', 'Good', 'Very good', 'Excellent'];

document.querySelector('.stars').addEventListener('change', (event) => {
  const value = Number(event.target.value);
  document.querySelector('.score').textContent = `${value} out of 5: ${WORDS[value - 1]}`;
});
```

The radio buttons are the same as in the first fix, and they are what passes all three criteria. What changes is the resting look: a line of words beside the stars, "Not rated yet", and then "4 out of 5: Very good" as the choice moves. The stars, their size and their colours stay as drawn. The words give the rating to everyone who reads the form, not only to people who count filled stars: someone who can't tell a filled star from an empty one at a glance, someone using a magnifier who sees two stars at a time, someone who isn't sure what four stars means on this site. The line isn't a live region: a screen reader already hears something like "4 stars, radio button, checked" as the choice moves, and the words are there when it reads the form.

## Which option to use

All three pass. Radio buttons over the drawn stars keep the design exactly as it is, with no script, and leave the keys, the focus, the form value and the touch position to the browser; use them whenever the stars can be labels. The ARIA radio group keeps elements a script or a component draws, at the cost of writing and testing the keys, the focus and the states yourself; use it only when the stars can't be labels. Writing the rating in words beside the stars is the designer's choice: it adds a line to the form and gives the rating to people who can't, or would rather not, count stars. Decide it in the design file, where there is room for the words on a phone as well as on a desktop.

## Implementation decisions

The classic CSS-only star rating writes the radio buttons from 5 down to 1 and turns the row round with `flex-direction: row-reverse`, so that `input:checked ~ label` can fill the stars after the checked one, which then appear before it. Don't copy it: a screen reader then reads "5 stars" first, and the arrow keys move the choice the opposite way to the arrow pressed. `:has()`, as in the fixes, fills the stars before the checked one with the source in order.

Radio buttons can't be unchecked. A rating that can be left empty needs a way back, such as a "Clear rating" button after the stars, which unchecks the group in a script and puts focus back on the first star. A rating that must be given can carry `required` on its radio buttons: the browser then moves focus to the group and says what is missing when the form is sent.

Many designs preview the rating as the pointer moves along the stars. A preview drawn on `:hover` alone does no harm when the radio buttons are there, because the arrow keys check each star as they move, so a keyboard user sees the real choice instead of a preview. Half stars need a radio button for each half, ten in all, named "half a star" to "5 stars", and halves drawn on each star; a script that reads which half of a star the pointer is on gives the keyboard no way to choose them.

In a Windows contrast theme, the browser replaces colours with the user's own, but keeps background images, so the stars keep their amber and grey there, and an outlined star can still be told from a solid one. The focus ring is drawn with `outline`, which the browser redraws in the theme's colour. Stars drawn as an SVG `fill` in a fixed colour can vanish against a dark theme: draw them with `currentColor`, as the W3C example does.

Checkboxes and radio buttons hidden in other ways, and the other fixes for them, such as drawing the box on the input with `appearance: none`, are covered in [custom checkboxes that keyboards and screen readers skip](/learn/custom-checkboxes). A rating shown after it is given, such as a product's average beside its name, is not a control: it needs the rating in words, "4 out of 5", as [aria-label on divs and spans that screen readers often skip](/learn/aria-label-on-generic) shows.

## Verify the fix

1. Put the mouse aside. (In Safari on a Mac, first turn on "Press Tab to highlight each item on a webpage" in its Advanced settings, or Tab can skip radio buttons on every site.) Tab to the rating: focus stops on it once, on the chosen star or on the first when none is chosen, with a visible ring. The arrow keys move the choice one star at a time, and the filled stars follow.
2. Choose a rating with the keyboard and send the form from the keyboard: the rating sent is the one the stars show.
3. With a screen reader, move to the stars: the question is announced, then each star's value, its role and whether it is chosen, and the chosen rating is read again when you leave the stars and come back.
4. With a Windows contrast theme on, or with forced colors emulated, the chosen stars can be told from the empty ones, and the focus ring shows.
5. On a phone with its screen reader on, touch each star: the radio button is found there, and a double tap chooses it.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the rating component or the theme changes.

## Limits

This is a starting pattern, not a guarantee for every application. The CSS is a sketch: check the size of the stars, their contrast and the focus ring against your own palette. Stars told apart only by colour, such as a grey star and a yellow star of the same shape, are a different failure, left to the topic on colour alone, whose guide is in progress. A group of radio buttons whose question isn't tied to them is covered in [radio buttons and checkboxes without a group name](/learn/field-groups). Test the whole review form with a keyboard, a screen reader and a phone.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
