Design a star rating widget
A design-system star rating: a read-only display that shows an average like 4.3 out of 5 with its review count, and an input where a person picks their own 1 to 5 stars. The hard part is that these are two different components with different semantics: the display is an image with a label and a fractional fill, the input is a radio group with arrow keys, hover preview, form integration, a way to clear, and an optimistic save.
The question
Design a star rating component for our design system. Product pages show the average rating of an item, like 4.3 stars from 1,204 reviews, and logged-in shoppers can rate the item themselves from 1 to 5 stars.
Walk me through how it looks, how it works with a mouse, a keyboard, a touch screen and a screen reader, how it fits into a review form, what the props are, and how a rating gets saved.
Real-life examples: Product stars and the rating histogram on an Amazon product page, App ratings in the Google Play Store and the Apple App Store, Rating a stay on Airbnb after checkout, Rating a driver at the end of an Uber trip, Rating a title on Goodreads or Letterboxd
Requirements: two components hiding in one
A star rating is a row of usually five stars. Everyone has seen two versions of it. On a product page, the stars under the title show what other people think: 4.3 out of 5, from 1,204 reviews. In a review form, the same row of stars asks you what you think: you click the fourth star and your rating is 4.
They look almost the same, so teams often build one component with a readOnly flag. That is the first thing to question in the interview. The two versions answer different questions, take different input, and must sound different to a screen reader. A strong answer splits them early and then designs each one properly.
| Aspect | Display (read-only) | Input (your rating) |
|---|---|---|
| What it shows | An average of many ratings, like 4.3 | One person's choice, a whole number like 4 |
| Precision | Any decimal: 4.3 needs 3 tenths of a star | Whole stars, sometimes half stars |
| Interaction | None. Maybe a link to the reviews | Click, tap, arrow keys, hover preview |
| Screen reader hears | "Rated 4.3 out of 5 from 1,204 reviews, image" | "4 stars, radio button, 4 of 5, checked" |
| HTML underneath | One element with role="img" and a label | Five native input type="radio" elements |
| Data | Average plus count, from a cached summary | The user's own value, saved with a PUT |
Questions to ask
Each question below changes a real decision. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Do we need both the display and the input? | Yes. Display on product cards and pages, input in the review form and in a quick "rate this" row. | Two components that share the star drawing, not one component with a flag. |
| How many stars, and what precision? | 5 stars. The display shows any average. The input takes whole stars; half stars are a stretch goal. | Whole stars fit 5 radios. Half stars need 10 radios or a slider. |
| Can a user remove their rating? | Yes, in the quick rate row. In the review form a rating is required. | Radios can't be unchecked natively, so we need a clear control. |
| Where does the input live? | Inside a review form (submitted with the form) and standalone (saves on click). | Form integration with name and required, plus a save-on-change mode. |
| How many ratings and reads? | Popular items have 100,000+ ratings. Product pages get thousands of views per second at peak. | The average is computed on the server and cached; the client never averages. |
| Which devices and users? | Phones first, plus desktop. Keyboard and screen reader users. High contrast mode on Windows. Right-to-left languages. | Touch has no hover; forced colors strip our colors; RTL fills from the right. |
| Do we show a breakdown? | Yes, a 5 to 1 star histogram on the product page; clicking a bar filters reviews. | A third small component and a filter in the URL. |
Functional requirements
- Show an average from 0 to 5 with a fractional fill, the number itself, and the review count, like "4.3 (1,204)".
- Let a signed-in user pick 1 to 5 stars with mouse, touch, or keyboard, and see which star they are about to pick before they commit.
- Show a word for each value, like "Terrible" to "Excellent", on hover and focus.
- Work inside a native
form: submit under aname, supportrequired, reset with the form, show a validation message. - Save the user's own rating, update the shown average right away, and roll back if the save fails.
- Show a histogram of 5, 4, 3, 2 and 1 star counts that filters the review list.
Non-functional requirements
- Accessible: correct roles and names for both components, the standard radio group keyboard model, a visible focus ring, filled and empty stars that differ by shape and not only by color (WCAG 1.4.1), and 3:1 contrast for the star outlines (WCAG 1.4.11).
- Light: a product grid shows 48 cards, each with a display. The display must be pure HTML and CSS with no JavaScript, so it costs nothing to hydrate. The input can be a small client component, about 2 to 3 KB gzipped.
- Works before JavaScript loads: a review form submitted on a slow 3G phone must still send the rating.
- Correct under load: one person, one rating per item. Double clicks and retries must not create duplicate ratings.
- International: numbers formatted per locale ("4,3" in German), labels translated, stars fill from the right in Arabic and Hebrew.
Out of scope
Review text, photo uploads, moderation, fake review detection, and ranking reviews by helpfulness belong to a product reviews feature, not this component. Say so out loud, so the interviewer knows you chose to leave them.
Check your understanding · Exercise 1
Which answers from the interviewer change the design of the component itself? Select all that apply.
What makes five stars hard
Most candidates draw five stars, add an onClick to each, and move on. That version works for one person: a sighted mouse user on a desktop. Here is what it misses, and every later chapter fixes one of these.
- It's two components. A display of 4.3 and an input for your 4 need different HTML, roles and data.
- It's a picture of a number. The display must say the number in words, draw fractions like 0.3 of a star, and always show the count.
- It's a form control in disguise. Choosing one value from five is a radio group; five buttons break the keyboard model, form submission,
requiredand reset. - It holds three values at once: the saved value, the value under the pointer (the hover preview), and the value on the server. Mixing them up gives stars that flicker, forget, or lie.
- It depends on color. Gold versus grey is lost on colorblind users, in high contrast modes and on paper.
- It writes to a shared number. The rater wants instant feedback; the server must count each person once.
Think first
A team ships five span elements with click handlers and an aria-label on each. List three things a keyboard or screen reader user can't do with it.
They can't reach it with Tab, because a span isn't focusable. Even with tabindex="0" on each, it takes five Tabs to pass, and arrow keys do nothing.
ARIA doesn't allow naming an element with no role, so screen readers may ignore the aria-label. At best the user hears "1 star" to "5 stars" as text, with no role, no "checked" state and no "4 of 5".
Nothing submits with a form, required does nothing, and reset doesn't clear it. Native radios give all of that for free.
The wireframe shows the three pieces this guide designs: the summary (stars, number, count), the histogram, and the rating input. The rest of the page is out of scope.
The display: drawing and saying 4.3 out of 5
Start with the read-only display, because it's on every product card and page. The naive version rounds the average with Math.round(4.3) and draws 4 gold stars and 1 grey star.
Three things go wrong. The picture lies: 4.3 and 4.0 look the same, and 4.6 jumps to 5 full stars. A screen reader hears silence, or "star, star, star, star, star" if each icon has alt text. And a lone row of stars hides how many people voted.
What a screen reader should hear
Decision How should the read-only display expose its value to assistive technology?
What we pick: Use one role="img" element with an aria-label that holds the whole sentence. It reads as one image with one clear name, and it costs no JavaScript. Keep the review count outside the image when it's a link to the reviews, because a link can't live inside an image.
<div class="rating-summary">
<span role="img" aria-label="Rated 4.3 out of 5" class="stars"
style="--whole: 4; --part: 0.3">
<!-- 5 empty star SVGs, then a .filled row of 5 clipped by CSS -->
</span>
<span aria-hidden="true">4.3</span>
<a href="#reviews">1,204 reviews</a>
</div>
A screen reader typically announces "Rated 4.3 out of 5, image", then "1,204 reviews, link". The visible "4.3" is hidden from it because the image label already says it. When the count is not a link, put it in the label instead: "Rated 4.3 out of 5 from 1,204 reviews". With zero reviews, show "No reviews yet" as text, not five empty stars, which read as a rating of 0, the worst score.
Drawing 0.3 of a star
There are two common ways to fill part of a star. The clip approach stacks two rows: five empty stars below, five filled stars on top, and clips the top row so only the first 4.3 stars show. The gradient approach gives each star its own SVG linearGradient with two hard stops at the same offset, so the fourth star is fully gold and the fifth is gold up to 30% and grey after.
whole x (size + gap) + part x size.The clip has a trap. If you clip the top row at 4.3 / 5 = 86% of its width, you forget the gaps between stars. With 20 px stars and 4 px gaps, the row is 116 px wide, and 86% is 99.8 px: only 3.8 px into the fifth star, about 19% of it instead of 30%.
The fix is to measure in stars and gaps: the filled width is 4 x (20 + 4) + 0.3 x 20 = 102 px. In CSS that's a calc() on custom properties, so it still needs no JavaScript.
.stars { --size: 20px; --gap: 4px; position: relative; display: inline-flex; gap: var(--gap); }
.stars .filled {
position: absolute; inset: 0; display: inline-flex; gap: var(--gap);
/* --whole and --part are set from the server: 4 and 0.3 */
clip-path: inset(0 calc(100% - (var(--whole) * (var(--size) + var(--gap))
+ var(--part) * var(--size))) 0 0);
}
:dir(rtl) .stars .filled {
clip-path: inset(0 0 0 calc(100% - (var(--whole) * (var(--size) + var(--gap))
+ var(--part) * var(--size))));
}
The gradient handles gaps naturally, because each star fills itself. Its cost is ids: fill="url(#half)" looks up a gradient by id in the whole document, so on a grid of 48 cards repeated ids make every card show the first card's fill, and each instance needs a unique id from React's useId. This guide picks the clip: one CSS rule, no ids, and one more rule for right-to-left.
Rounding: what the stars and the number show
| Average | Exact fill | Nearest half star | Number shown |
|---|---|---|---|
| 4.30 | 4 stars and 30% | 4.5 stars | 4.3 |
| 4.24 | 4 stars and 24% | 4 stars | 4.2 |
| 4.75 | 4 stars and 75% | 5 stars | 4.8 |
| 4.96 | 4 stars and 96% | 5 stars | 5.0 |
Exact fill is honest, but a sliver under about 15% of a star is hard to see. Nearest half is easy to read, but at 4.75 it draws five full stars, which looks perfect; rounding down to the half avoids that. Our choice: exact fill, plus the number with one decimal from Intl.NumberFormat (4,3 in German), plus the count. Pass the locale explicitly, so the server and the browser produce the same string and hydration doesn't mismatch.
Check your understanding · Exercise 2
A product card shows the average as span role="img" aria-label="Rated 4.3 out of 5" with five star SVGs inside. Which statements are true? Select all that apply.
The input: five radios dressed as stars
Now the part people interact with. What is this control, really? The user picks exactly one value from a short, fixed list: 1, 2, 3, 4 or 5. HTML has a control for exactly that, a group of radio buttons sharing one name, and the stars are just how the radios look.
Decision What HTML should sit under the five clickable stars?
What we pick: Use five native radios with visually hidden inputs and star labels. It's the most dependable option because the browser and the screen reader already agree on how radios behave. The APG's rating example uses the same radio group semantics, built with ARIA roles and its own keyboard script; native radios give that model for free. We only add what radios lack: a hover preview and a way to clear.
name="rating", the browser makes one group: one Tab stop, arrow keys, one value in the form, and a position like "4 of 5".<fieldset class="rating-input">
<legend>Your rating</legend>
<input class="sr-only" type="radio" name="rating" value="1" id="r1" required>
<label for="r1"><svg aria-hidden="true">...</svg>
<span class="sr-only">1 star, Terrible</span></label>
<!-- r2 to r4 the same -->
<input class="sr-only" type="radio" name="rating" value="5" id="r5">
<label for="r5"><svg aria-hidden="true">...</svg>
<span class="sr-only">5 stars, Excellent</span></label>
</fieldset>
Two details matter. First, "visually hidden" means the sr-only pattern (1 px size, clipped, absolutely positioned), never display: none or visibility: hidden, which remove the radio from the Tab order and from the accessibility tree.
Second, the stars up to the checked one must fill. With :has(), supported in all major browsers since late 2023, the fieldset can ask which radio is checked and fill every label up to it. Older code reverses the DOM order and uses the ~ sibling combinator; :has() keeps the DOM in reading order, which screen readers need.
The keyboard model you get for free
| Key | What native radios do | Why it matters for ratings |
|---|---|---|
| Tab | Enters the group at the checked radio. If none is checked, at the first one (most browsers pick the last one with Shift+Tab). One more Tab leaves the group. | The whole rating is one Tab stop, like the roving focus in a tabs component. |
| Right or Down arrow | Moves to the next radio and checks it. From the last, wraps to the first. | Arrowing changes the value. That's fine in a form but matters for save-on-change, below. |
| Left or Up arrow | Moves to the previous radio and checks it, wrapping from the first to the last. | Lowering a rating is one key press. |
| Space | Checks the focused radio if it isn't checked. | The way to pick the first star when you entered an empty group. |
The browser does all of this without a line of script. A screen reader announces something like "Your rating, group, 4 stars, Good, radio button, checked, 4 of 5". The exact words differ between NVDA, JAWS and VoiceOver, but every part comes from the native markup: the legend, the label, the role, the state and the position.
Letting people take a rating back
Once a radio in a group is checked, the user can't return to none checked: clicking a checked radio does nothing, and so does Space. That's right for a required review form, but in a quick "rate this" row people misclick and want to undo. Three ways to allow it:
- A visible "Clear rating" button, shown only when there is a value. Everyone can find it. When it's pressed and hides, move focus into the radio group, or focus falls to the page body and a keyboard user is lost.
- Click the checked star again to clear. Pointer users find it by accident, but keyboard users can't do it, so it can only be an extra.
- A sixth radio, "No rating", before the first star. It's fully native and works with no JavaScript, but it adds a stop to the arrow cycle and needs its own visual, often a small "x".
This design uses the visible button, with click-again-to-clear as a bonus for pointer users. The allowClear prop turns both on; with required, they stay off.
Check your understanding · Exercise 3
The rating input uses five native radios hidden with the sr-only pattern. Which statements are true? Select all that apply.
Half stars: ten radios or a slider
Some products allow half stars; Letterboxd, for example, lets members rate films from half a star to five. Five radios can't express 3.5, so the input needs ten values, 0.5 to 5. The pointer part is easy: each star gets two hit areas, so the left half of the fourth star means 3.5 and the right half means 4, swapped in right-to-left layouts. The real choice is the semantics.
Decision How should a half-star input be built?
What we pick: Default to whole stars: they are faster to choose and give cleaner data. When the product really needs halves, use ten native radios with clear labels, because it keeps the form, the no-JavaScript path and the "not rated yet" state. Reach for a slider only when the scale is truly continuous, such as a 0 to 10 score in steps of 0.1.
Whichever you pick, the value sent to the server stays a number with a fixed set of allowed values, and the server checks it. A crafted request with rating: 3.7 or rating: 9 must be rejected with a 422, not stored.
Check your understanding · Exercise 4
A team builds the half-star input as a custom role="slider". Which problems must they solve that ten native radios would have solved for them? Select all that apply.
Hover preview: showing a value without choosing it
On desktop, hovering the third star lights up stars one to three and shows the word "Okay"; moving away restores the saved value. That preview shows what a click will do. The bug to avoid is letting it become the value: if onMouseEnter calls setValue, passing the mouse over the stars changes the rating and fires a save.
So the input keeps two separate things. The committed value is what the radios hold and what a form submits. The preview value exists only while a mouse hovers, and the stars draw it when it's there. Only a click, a tap or a key changes the committed value, and only that fires onChange.
const [preview, setPreview] = useState<number | null>(null);
const shown = preview ?? value; // what the stars draw
function onPointerEnter(e: React.PointerEvent, star: number) {
if (e.pointerType !== "mouse") return; // touch and pen: no hover
setPreview(star);
}
// on the fieldset, so moving between stars doesn't flicker:
const onPointerLeave = () => setPreview(null);
onChange runs through a radio becoming checked, by click, tap, or key.Touch has no hover
On a phone, a tap also fires compatibility mouse events such as mouseenter, and the browser keeps the tapped element in the :hover state until the next tap elsewhere. A preview driven by mouseenter or :hover therefore sticks on the screen. Pointer events behave better, because pointerleave fires after the finger lifts, but then the preview only flashes, which is noise.
So the handler ignores pointers whose pointerType isn't mouse, and any CSS hover styles go inside @media (hover: hover), which matches only when the main pointer can hover. Phones then show the committed value only, and a tap commits straight away, which is what people expect on touch.
The preview can even live in CSS alone: when a label is hovered, fill it and every label before it, and unfill the ones after. That keeps the no-JavaScript version pleasant on desktop. The JavaScript version is still useful for the word under the stars.
Words for each star
Labels like "Terrible, Poor, Okay, Good, Excellent" make the scale concrete. They appear in two places: in each radio's accessible name ("4 stars, Good"), so screen reader users hear them, and in a small visible caption that shows the word for the previewed or chosen value. The caption is marked aria-hidden, because the name already says it. As the tooltip guide argues, a tooltip should never be the only place information lives; here a tooltip-style bubble would be only a visual extra.
Think first
Should keyboard focus show a preview, the way hover does?
No. Arrow keys move focus and check the radio at the same time, so the focused star already is the value.
Keyboard users need a visible focus ring on the current star and the word caption updated with the value, which need no extra state.
Forms and the component API
The input will sit inside review forms written by other teams. Native radios already bring most form behavior; the job is to not break it, and to expose it through props that feel like any other input.
What the form gets from native radios
- Submission: radios with
name="rating"sendrating=4in the form data, and nothing when none is checked, which the server reads as "no rating". - Required:
requiredon any radio makes the group required, and the browser blocks submit with its own message on the first radio. For a custom message, callsetCustomValidityon the first radio and clear it when a star is picked, or show your own text under the stars, linked witharia-describedby. - Reset: a form reset restores each radio to its default, the
checkedattribute in the HTML (defaultCheckedin React). - No JavaScript: the server-rendered HTML is a complete form. On a phone that hasn't loaded the bundle yet, tapping a star and pressing Submit still sends the rating.
Here is a trap that catches many React implementations. If the stars are drawn from React state, a form reset changes the radios but not the state, because reset fires no change event. The stars still show 4 while the form holds nothing.
The fix is to draw the committed fill from CSS (:checked with :has()) and use React state only for the hover preview. Then a reset, the checked state Firefox restores after a reload, and the no-JavaScript page all draw correctly with no syncing code. If the component is controlled (the parent owns the value), it should also listen for the form's reset event and call onChange(null).
Decision Should the input be controlled or uncontrolled?
What we pick: Support both, exactly like a native input: value makes it controlled, defaultValue makes it uncontrolled, and switching between the two logs a warning in development. The standalone "rate this" widget uses controlled mode, because it must roll back failed saves; review forms usually use uncontrolled mode.
The props
The component API lesson's rule applies: make the common case one line, and name things the way the platform does. Two exports, not one with a flag: StarRating for the input and RatingDisplay for the read-only picture.
type StarRatingProps = {
value?: number | null; // controlled; null means not rated
defaultValue?: number | null; // uncontrolled
onChange?: (value: number | null) => void;
onPreviewChange?: (value: number | null) => void; // hover, for analytics or a caption
max?: number; // default 5
precision?: 1 | 0.5; // default 1
labels?: string[]; // ["Terrible", ..., "Excellent"], one per whole star
legend: string; // "Your rating"; required, it names the group
name?: string; // form field name
required?: boolean;
allowClear?: boolean; // shows "Clear rating"; ignored when required
disabled?: boolean;
size?: "sm" | "md" | "lg"; // maps to design tokens: 16, 24, 32 px
};
type RatingDisplayProps = {
value: number; // the average, 0 to 5
count?: number; // number of ratings
max?: number;
rounding?: "exact" | "half"; // default "exact"
size?: "sm" | "md" | "lg";
href?: string; // makes the count a link to the reviews
};
onChange gets a number or null, never an event, because consumers want the value. legend is required, because an unnamed radio group is an accessibility bug the type system can catch. There is no readOnly: a read-only rating is a picture, so teams use RatingDisplay instead of five disabled radios that a screen reader would announce as unavailable controls. disabled stays for the short moments when the input exists but can't be used.
Check your understanding · Exercise 5
A controlled StarRating draws its filled stars from React state. A user picks 4 stars and then presses the form's Reset button. What happens, and what fixes it? Select all that apply.
Visual design: stars that survive any screen
The stars look simple, but they are shown at 16 px on a product grid and 32 px in a review form, in light and dark themes, in Windows high contrast mode, in right-to-left languages, and sometimes printed. The way you draw a star decides which of those still work.
Decision How should a star be drawn?
What we pick: Inline SVG from one sprite, colored with currentColor. It's the only option that combines a predictable shape, clean partial fill, and good behavior in forced colors mode.
Filled and empty must differ by shape, not just color
The classic design is gold filled stars and grey filled stars, and it fails three groups of people. Users with color vision deficiency may see gold and grey as similar. Gold #F5A623 on white has a contrast of about 2 to 1, under the 3 to 1 that WCAG 1.4.11 asks of graphics that carry meaning.
The third group uses forced colors mode (Windows contrast themes), where the browser swaps author colors for a small palette of system colors such as Canvas and CanvasText. Stars drawn as text glyphs or CSS backgrounds all turn the same system color, so every star looks filled. Inline SVG keeps hard-coded fills by default, so gold and grey stay as they are, ignore the user's theme, and can vanish against its background.
The fix is to make the difference a shape: filled stars are solid, empty stars are outlines. Both use currentColor, so in forced colors mode the SVG takes its parent's forced text color and the shapes still read clearly. For full control, an @media (forced-colors: active) rule can color the stars CanvasText and the input's chosen stars Highlight, as native controls do; check it with the browser's forced colors emulation. In normal mode, a darker stroke around the gold brings the outline to 3 to 1.
A focus ring on a hidden input
The hidden radios' own focus ring is invisible, so move it to the star: input:focus-visible + label gets a 2 px outline with an offset. Use outline, not box-shadow: forced colors mode removes box shadows but keeps outlines, in a system color. :focus-visible matches keyboard focus and usually not mouse clicks, like native controls.
Right to left
In Arabic or Hebrew, the first star sits on the right and the stars fill from right to left. A flex row does the layout for free, because flex follows the writing direction. Two places need care: the display's clip must cut from the other side (the :dir(rtl) rule from the display chapter), and the half-star hit areas swap. The star shape is symmetric, so it never needs mirroring, but do check which way the arrow keys move in your target browsers and screen readers.
Check your understanding · Exercise 6
A user turns on a Windows high contrast theme. Which designs still show 3 filled and 2 empty stars clearly? Select all that apply.
Architecture and data: who owns which number
Zoom out to the product page. It shows three numbers: the average and count everyone sees, the histogram of how many people gave each star, and my rating, the signed-in user's own value. They come from different places and change at different speeds, so they live in different places.
| Piece | Responsibility | Why the boundary is here |
|---|---|---|
RatingDisplay | Draws an average with its fraction, label and count. A pure component: no state, no effects, no handlers. | On a grid of 48 cards it ships no JavaScript; inside the rating island it just re-renders with new props. |
RatingSummary | The product page header: display, number, count link to the reviews. | Server-rendered with the page, because it matters for the buying decision and for search results. On the product page it sits in the rating island, so it can show the optimistic average. |
RatingHistogram | Five bars, each a link that filters the review list. | Server-rendered too; filtering is just a URL change. |
StarRating (my rating) | The input. Committed value, hover preview, clear. | The only client-side piece: a small island that hydrates on its own. |
| Rating store | Holds the summary and my rating per product, applies optimistic changes, rolls back. | One copy per product, so every place on the page that shows it agrees. |
| API client | Sends PUT and DELETE for my rating, one request at a time, and handles 429. | Keeps network rules out of the components. |
The data model
type Stars = 1 | 2 | 3 | 4 | 5;
type RatingSummary = {
productId: string;
histogram: [number, number, number, number, number]; // counts for 1 to 5 stars
count: number; // sum of the histogram
average: number; // exact, e.g. 4.264; the UI rounds for display
updatedAt: string; // ISO time, for cache checks
};
type MyRating = {
productId: string;
value: Stars | null; // null: not rated
updatedAt: string | null;
};
// Lives only in the component: never sent, never cached.
type RatingUiState = { preview: Stars | null; saveState: "idle" | "settling" | "saving" | "failed" };
| Data | Where it lives | Freshness |
|---|---|---|
RatingSummary | In the server-rendered HTML, then the client store. Cached at the CDN with the page. | A minute old is fine for everyone except the person who just rated. |
MyRating | Fetched per user after hydration, marked Cache-Control: private, no-store. | Must be exact for this user; never cached at the CDN. |
| Hover preview | Component state. | Lives for the length of a hover. |
| Histogram filter | The URL, like ?stars=4. | Shareable and survives reload. |
Why send the histogram instead of just the average? Because the client must update the average when you rate, and it can't do that correctly from a rounded number.
Take 1,204 ratings with counts of 64, 42, 108, 288 and 702 for 1 to 5 stars. The sum of all stars is 64 + 84 + 324 + 1,152 + 3,510 = 5,134, and the average is 5,134 / 1,204 = 4.264, shown as 4.3. Add a 5-star rating and it becomes 5,139 / 1,205 = 4.265, still 4.3. With whole counts the math is exact, so the client and the server always agree.
PUT from the API client, and the summary in its response replaces the optimistic one.The figure follows both directions. Reads: the counters feed the page HTML, which the CDN caches and the browser draws before any JavaScript runs; my rating is a separate, private request. Writes: the island changes the store first, the API client sends one PUT, and the summary in the response replaces the optimistic one.
The histogram: the shape behind the average
An average of 3.0 can mean everyone thinks the product is okay, or that half love it and half hate it. The histogram, five bars for 5 down to 1 star, shows which. On a product page it also works as a filter: clicking the 1-star bar shows only 1-star reviews, the quickest way to see the worst complaints.
?stars=1, whose text says the whole fact; the bar is decoration. Rounding each share on its own can total 99 or 101.Each bar is a link, like ?stars=1, whose text is the full fact: "1 star, 64 reviews, 5%". The bar itself is a decorative div sized from the percentage and hidden from screen readers, because the link text already says it. Links, not buttons, because the result is a new URL that can be shared and reloaded.
The selected filter gets aria-current="true" and a visible "Clear filter" link. When the review list updates in place, move focus to the list heading or announce "Showing 64 one-star reviews" in a polite live region, so keyboard and screen reader users know something happened.
One small trap: rounded percentages may not add up to 100. With counts of 702, 288, 108, 42 and 64, the exact shares are 58.3, 23.9, 9.0, 3.5 and 5.3, which round to 58, 24, 9, 3 and 5: a total of 99. If the design needs 100, use the largest remainder method: round every share down (58, 23, 8, 3, 5), then give the 3 missing points to the largest fractions (8.97, 23.92 and 3.49), which gives 58, 24, 9, 4 and 5.
Format each share with Intl.NumberFormat and style: "percent", because languages differ in where the sign goes and whether a space comes before it, as the poll widget guide shows. Consider hiding the bars when a product has only a handful of ratings, because two reviews make a dramatic but meaningless chart.
Check your understanding · Exercise 7
Which statements about where rating data lives are true? Select all that apply.
Submitting a rating: instant, correct, and counted once
In a review form, the rating goes with the form. The standalone "rate this" row is different: a click must save on its own. The naive version calls POST /ratings with { productId, stars } on every change and then refetches the summary.
Count what goes wrong. A retry after a timeout can store a second rating. A keyboard user arrowing from 1 to 4 sends three requests, the old average shows until the refetch returns, and if two requests race, the older one can land last.
Decision What request saves my rating?
What we pick: Use PUT on "my rating", and DELETE to clear. It matches the data (each user has one rating per product), and its idempotency makes retries harmless without an extra idempotency key.
PUT /api/products/p_812/my-rating
Content-Type: application/json
{ "value": 4 }
HTTP/1.1 200 OK
{ "myRating": { "value": 4, "updatedAt": "2026-10-20T16:02:11Z" },
"summary": { "histogram": [64, 42, 108, 289, 702], "count": 1205,
"average": 4.264 } }
DELETE /api/products/p_812/my-rating -> 200 with the new summary
PUT with { "value": 9 } -> 422 { "error": "value must be 1 to 5" }
Too many writes -> 429, Retry-After: 30
Optimistic update of my rating and of the average
Rating should feel instant, and it almost always succeeds, so it's a good fit for an optimistic update: change the screen first, then send the request, and undo the change if it fails. It's the same move as the vote in the poll widget guide. On click, save a snapshot of the summary and my rating, then change both locally.
My rating becomes 4. In the histogram, the old value's bar loses one (if there was an old value) and the 4-star bar gains one; the count grows only if there was no old value. The average is recomputed from the new histogram. When the response arrives, the server's summary replaces the local one, which also picks up other people's ratings.
PUT is retried first; only when retries run out does the snapshot come back, with a message.Wait for the value to settle, and send one request at a time
Arrow keys produce a burst of values, and so does a user who changes their mind. Two rules keep the network calm. First, settle: after a change, wait about 500 ms with no further change before sending; the stars update instantly, and only the request waits. If the page is hidden (visibilitychange) while a value waits, send it at once with fetch and keepalive: true, so the request survives the tab closing.
Second, one at a time: never have two writes in flight. If the value changes while a PUT is out, remember only the newest value and send it when the first returns. Otherwise a PUT of 3 and a PUT of 4 can reach the server in the wrong order, and 3 wins. Aborting the first request doesn't help, because the server may already be processing it.
PUT requests are never out at the same time.These two rules also keep a normal user far below the server's rate limit, for example 30 rating writes per user per minute, which stops scripts from flooding the counters. If the server still answers 429, the client keeps the optimistic value, waits for the time in Retry-After, and tries once more before rolling back with a message.
When it fails
- Network error, timeout or 5xx: the server may have saved the value before the answer was lost.
PUTis idempotent, so retry the same value once or twice with backoff, as the optimistic updates lesson advises. If that fails, roll back the stars and the summary and show "Couldn't save your rating. Try again" in a polite live region next to the stars, keeping focus where it is. - 401, signed out: a signed-out user sees the stars with "Sign in to rate". Clicking a star keeps the value in
sessionStoragethrough sign-in and applies it on return, so the intent isn't lost. - 422: a bug or a tampered request. Roll back and log it; the user can't fix it.
- Clear:
DELETEis optimistic too: the stars empty, the histogram bar and count drop by one, and failure restores them.
Check your understanding · Exercise 8
A user changes their rating from 2 to 3 to 4 quickly on a slow network. Which client behaviors keep the server's final value at 4? Select all that apply.
Testing the widget
A star rating is small enough to test thoroughly, and most of its bugs are in behavior and semantics, which tools can check. Here is what each testing layer covers for this component.
- Unit tests for the math: the clip width for 4.3 with gaps, half-star rounding (4.24, 4.25, 4.75), the average and percentages from a histogram, and the optimistic histogram change for new, changed and cleared ratings.
- Component tests with Testing Library:
getByRole("group", { name: "Your rating" })andgetByRole("radio", { name: /4 stars/ })only work if the semantics are right. Then test arrows, Space, reset,required, and that hovering never callsonChange. - Display tests:
getByRole("img", { name: "Rated 4.3 out of 5" })exists, and with zero reviews it doesn't. - Network tests with a mocked API: a rapid 2, 3, 4 sends exactly one
PUTwith 4; a 500 that outlasts the retries rolls back and shows the error; a429waits forRetry-After. - Automated accessibility checks: axe on the display, the empty and checked input, and the error state. It catches missing names and low contrast, not keyboard logic.
- End to end and visual tests: a keyboard-only run through the review form in real browsers, and screenshots in light, dark, forced colors (Playwright can emulate
forced-colors: active) and right to left. - Manual screen reader passes: NVDA with Firefox or Chrome, VoiceOver with Safari on macOS and iOS, TalkBack on Android.
Watch production too: log save failures by status code, and track how often people change or clear a rating within a few seconds. A spike in quick changes often means misclicks, for example stars too small to tap.
Check your understanding · Exercise 9
Which checks would catch a rating input built from five span elements with click handlers? Select all that apply.
Running it in the interview
A star rating is usually a 30 minute question, and the interviewer is checking whether you see past the picture. The strongest move is to split the two components in the first five minutes. Here is one way to spend the time.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask the scope questions. Draw the wireframe: summary, histogram, your rating. | "I see two components: a display of an average and an input for one person's value. They need different semantics." |
| 5 to 12 | The input: radios under the stars, keyboard table, clearing. | "Picking one of five is a radio group, so I'll use native radios and get the keyboard, form and screen reader behavior for free." |
| 12 to 17 | The display: role="img", label, fractional fill, rounding, count. | "The number carries the precision; the stars give the shape; the count gives the trust." |
| 17 to 22 | Hover preview, forced colors, focus ring, RTL, props. | "Preview is separate state and never fires onChange. Filled versus empty is a shape, not a color." |
| 22 to 28 | Saving: PUT my rating, optimistic histogram, settle and one-at-a-time, rollback. | "Idempotent PUT, one write in flight, the server's summary wins." |
| 28 to 30 | Testing and what you'd do next. | "getByRole queries prove the semantics; one manual screen reader pass per platform." |
Draw the wireframe first, because it makes the split obvious, then the radio anatomy. Leave the architecture for later: for a component question it's three boxes, and the interviewer cares more about the HTML than about servers.
Follow-up questions to expect
Think first
Product asks to replace stars with thumbs up and thumbs down. What changes?
The input becomes a choice of two: still a radio group, or two toggle buttons with aria-pressed if "neither" is normal and clicking again undoes. The display becomes a percentage, like "92% positive from 1,204 ratings".
The API keeps its shape: PUT my rating with { value: "up" }, with the same optimistic update and the same settle and one-at-a-time rules.
Think first
A big sale sends 50,000 ratings a minute to the same product. Does the frontend design change?
Barely. The client already sends one PUT per settled change, and the summary is cached at the CDN, so reads don't grow with writes. The server should update counters in batches and refresh the cached summary every few seconds.
The rater still sees their change at once, from the optimistic update and the PUT response. Other shoppers see it within the cache lifetime, which is fine for an average of thousands.
Summary
- A star rating is two components: a read-only display of an average and an input for one person's value. They share the star drawing, not the semantics.
- The display is one
role="img"with a label like "Rated 4.3 out of 5", a fractional fill computed from whole stars plus gaps, the number with one decimal, and always the count. - The input is five native radios, visually hidden, with star labels. The browser provides one Tab stop, arrow keys, the checked state, form submission,
requiredand reset; JavaScript adds only hover preview and clearing. - Keep the hover preview separate from the committed value, ignore hover on touch, and never call
onChangefrom a hover. - Draw filled and empty stars as solid versus outline with
currentColor, put the focus ring on the label withoutline, and fill from the right in RTL. - Save with an idempotent
PUTon my rating, update the histogram optimistically, wait for the value to settle, keep one write in flight, and let the server's summary win. - Send the histogram, not just the average, so the client can recompute an exact average after a change.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA APG: Radio Group Pattern The keyboard model the native radios follow.
- WAI-ARIA APG: Rating Radio Group Example A star rating built as a radio group.
- WAI-ARIA APG: Slider Pattern For a continuous scale, with
aria-valuetext. - MDN: input type="radio" Groups,
required,checkedand form data. - MDN: forced-colors media feature How high contrast modes change your colors.
- MDN: :has() pseudo-class Filling stars up to the checked radio without reversing the DOM.
- WCAG 2.2: Understanding Non-text Contrast (1.4.11) The 3 to 1 rule for star outlines and focus rings.
- MDN: HTTP PUT Why
PUTis idempotent andPOSTis not. - CSS Color Adjustment Module: forced-color-adjust Why SVG keeps fixed colors in forced colors mode while
currentColorfollows the system text color.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code.