cf.completefrontend
My progress
SYSTEM DESIGN · #5

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.

AspectDisplay (read-only)Input (your rating)
What it showsAn average of many ratings, like 4.3One person's choice, a whole number like 4
PrecisionAny decimal: 4.3 needs 3 tenths of a starWhole stars, sometimes half stars
InteractionNone. Maybe a link to the reviewsClick, 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 underneathOne element with role="img" and a labelFive native input type="radio" elements
DataAverage plus count, from a cached summaryThe 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.

QuestionAnswer we assumeWhat 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 a name, support required, 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, required and 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.

Wireframe of the ratings area on a product page for a trail running shoe. At the top, the rating summary: four full stars and a fifth star 30 percent filled, the number 4.3, and a link reading 1,204 reviews; a note says it is one image with a label plus a link. Below, the histogram: bars for 5 star at 58 percent, 4 star 24 percent, 3 star 9 percent, 2 star 3 percent and 1 star 5 percent; a note says each bar is a link that filters the reviews. At the bottom, a Your rating panel with five stars, three of them filled, the word Okay, and a Clear rating button.Product page: Trail runner shoeTrail runner shoe★★★★30%4.31,204 reviewsRatingSummaryrole=img + a link5 star58%4 star24%3 star9%2 star3%1 star5%RatingHistogrameach bar: a link?stars=4Your rating★★★☆☆OkayClear rating
Three pieces, two kinds of component. The summary and the histogram only show data and can be plain server HTML. The Your rating row is the only interactive part.

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?

A role="img" wrapper with aria-label

Wrap the stars in one element with role="img" and aria-label="Rated 4.3 out of 5".

Children of an image role are presentational, so the five SVGs aren't read one by one.

Pros

  • One stop and one clear sentence for a screen reader.
  • Matches what the stars are: a picture of a number.
  • Static HTML; no JavaScript.

Cons

  • Nothing interactive can sit inside it, because image children are hidden from assistive technology.
  • The label must be translated and formatted like any other text.

Visually hidden text next to hidden stars

Mark the stars aria-hidden="true" and add an sr-only span: "4.3 out of 5 stars".

Pros

  • Plain text, read the same way by every screen reader.
  • Found by in-page search and by translation tools.

Cons

  • Easy to forget the aria-hidden and get both the stars and the text read.
  • If the visible "4.3" isn't hidden as well, the user hears the number twice.

A meter element

Use meter min="0" max="5" value="4.3" styled to look like stars.

Pros

  • Semantically, a meter is a value within a known range.

Cons

  • Screen reader support for meter is uneven; the value may be read as a bare number or a percentage instead of "4.3 of 5".
  • Hard to style consistently across browsers.

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.

html
<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>
The display as plain HTML: one image with a label, then a link for the count.

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.

Two ways to clip the filled row for an average of 4.3, with 20 pixel stars and 4 pixel gaps. Top, naive: clip at 86 percent of the 116 pixel row; the clip line lands only 19 percent into the fifth star, so it looks like 4.2. Bottom, fixed: clip at 4 times 24 plus 0.3 times 20, which is 102 pixels; the fifth star is 30 percent filled, a true 4.3.naive: clip at 4.3 / 5 = 86% of the 116 px row★★★★19%fifth star 19% filled: looks like 4.2fix: 4 x (20 + 4) + 0.3 x 20 = 102 px★★★★30%fifth star 30% filled: really 4.3
A percentage of the row includes the gaps, so the fraction comes out short. Measure in whole stars plus gaps, then add the fraction of one star: 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.

css
.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 clip in CSS: whole stars plus gaps, then the fraction of one star.

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

AverageExact fillNearest half starNumber shown
4.304 stars and 30%4.5 stars4.3
4.244 stars and 24%4 stars4.2
4.754 stars and 75%5 stars4.8
4.964 stars and 96%5 stars5.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.

Stars
Empty starsReviews
Rated 4.3 out of 5 from 1,204 reviews. Stars drawn at 4.3 with exact rounding, empty stars as outlines.★★★★30%aria-label: Rated 4.3 out of 5 from 1,204 reviewsshown next to the stars: 4.3 (1,204)clip at 86%: 19%whole + gaps: 30%stars drawn: 4.3filled vs empty: clear
Clipping the row at 86% ignores the gaps: the last star shows 19% instead of 30%.

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?

Five button elements

Each star is a button; clicking it sets the value. Maybe aria-pressed on the chosen one.

Pros

  • Quick to build. Each star is focusable and clickable.

Cons

  • Five Tab stops and no arrow keys.
  • aria-pressed describes a toggle, so nothing says "4 of 5, checked".
  • No form value, required or reset: all must be rebuilt.

ARIA radio group on div elements

A div role="radiogroup" with five div role="radio" children, aria-checked, and a roving tabindex managed in JavaScript.

Pros

  • Full control over markup and styling.
  • The correct roles, so screen readers announce it as a radio group.

Cons

  • You write the keyboard model yourself, and Tab entry, wrapping and Space are easy to get wrong.
  • Still no form value, required or reset, and nothing works before JavaScript loads.

Five native radios, visually hidden, with star labels

A fieldset with a legend, five input type="radio" with the same name, each hidden with the sr-only pattern and paired with a label that draws the star.

CSS fills stars up to the checked one; JavaScript only adds hover preview and clearing.

Pros

  • The browser gives you the keyboard model, the roles, the checked state, "4 of 5", form submission, required, reset and validation.
  • Works with no JavaScript at all.
  • Clicking a label checks its radio, so the star is the hit area.

Cons

  • Styling needs care: the focus ring must move to the visible star.
  • Native radios can't be unchecked, so clearing needs extra work.

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.

Anatomy of the rating input. A fieldset with the legend Your rating holds five radio inputs, r1 to r5, with values 1 to 5; r4 is checked. Under each radio is its label, drawn as a star; the first four stars are filled because r4 is checked. Notes say the inputs are visually hidden but focusable and in the accessibility tree, and that each label holds a star SVG plus hidden text such as 4 stars, Good. Below, the form data holds rating=4 with required and reset working, and a screen reader hears 4 stars, Good, radio button, 4 of 5.<fieldset> <legend>Your ratingr1value=1★r2value=2★r3value=3★r4value=4★r5value=5☆input: sr-only,focusable, inthe a11y treelabel: star SVG+ hidden text4 stars, GoodFormData: rating=4required and reset workScreen reader hears4 stars, Good, radio, 4 of 5
The radios do the work; the labels only draw. Because all five share name="rating", the browser makes one group: one Tab stop, arrow keys, one value in the form, and a position like "4 of 5".
html
<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>
The input's markup. The label text is the accessible name; the SVG is decoration.

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

KeyWhat native radios doWhy it matters for ratings
TabEnters 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 arrowMoves 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 arrowMoves to the previous radio and checks it, wrapping from the first to the last.Lowering a rating is one key press.
SpaceChecks 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?

Ten native radios

Ten radios, values 0.5 to 5, two half-width labels per star, named like "3.5 stars".

Pros

  • Keeps everything native: one Tab stop, arrows, form value, required, reset, no-JavaScript.
  • Each value has its own clear name.

Cons

  • The screen reader position says "7 of 10" on a 5-star scale, so the label must carry the meaning.
  • Each star splits into two targets. WCAG 2.5.8 asks for at least 24 by 24 CSS px per target, so each star grows to about 48 px wide.

A custom role="slider"

One focusable element with aria-valuemin="0.5", aria-valuemax="5", aria-valuenow="3.5" and aria-valuetext="3.5 stars, Good".

JavaScript handles arrows (step 0.5), Home, End, Page Up and Page Down, plus pointer drag.

Pros

  • Natural for a continuous scale; one stop, one announcement.
  • aria-valuetext reads a friendly value instead of a bare number.

Cons

  • Every key, the form value, required and reset must be written by hand, and nothing works before JavaScript loads.
  • A slider always has a value, so "not rated yet" needs a special state.

A native input type="range"

min="0.5" max="5" step="0.5", styled with stars over a transparent track, plus aria-valuetext.

Pros

  • Native keyboard, form value and reset, with no ARIA to maintain.

Cons

  • A range always holds a value (the middle by default), so it can't be empty, and required means nothing.
  • Styling the track and thumb as stars differs a lot across browsers.

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.

tsx
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);
Preview and committed value stay separate; the drawing picks one.
Sequence diagram of hover preview and commit, with four lifelines: Mouse, StarRating, Radios and Parent. The value is 3. The mouse sends pointerenter on star 5; StarRating draws a preview of 5 stars. The mouse leaves the group; StarRating draws the value 3 again. The mouse clicks star 5, which checks the fifth radio; the radio fires a change event to StarRating, which calls onChange with 5 on the Parent. The Parent hears nothing during the hover.MouseStarRatingRadiosParentpointerenterpreview: 5 starspointerleaveback to value 3click star 5change eventonChange(5)
Hover touches only local preview state, so nothing outside the component moves. The only path to 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.

Precision
Your rating
Screen reader says:Tab into the stars, then use the arrow keys. Or hover with a mouse and click.
Five stars drawing 0. value: empty. Change events so far: 0.☆Terrible☆Poor☆Okay☆Good☆Excellentnot ratedvalue: nonepreview: nonechange events: 0PUTs if saved per change: 0
Nothing chosen yet. Hover only previews; it never changes the value.

Think first

Should keyboard focus show a preview, the way hover does?

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" send rating=4 in the form data, and nothing when none is checked, which the server reads as "no rating".
  • Required: required on any radio makes the group required, and the browser blocks submit with its own message on the first radio. For a custom message, call setCustomValidity on the first radio and clear it when a star is picked, or show your own text under the stars, linked with aria-describedby.
  • Reset: a form reset restores each radio to its default, the checked attribute in the HTML (defaultChecked in 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?

Uncontrolled: defaultValue

The radios own the value; the parent reads it from the form data on submit, or listens to onChange.

Pros

  • Fits server actions and plain HTML forms.
  • Reset and no-JavaScript work with no extra code.

Cons

  • The parent can't force a value later, for example after a failed save.

Controlled: value and onChange

The parent passes value; the component renders checked from it and reports clicks through onChange.

Pros

  • The parent can roll back after a failed save, or sync two copies of the same rating on a page.

Cons

  • Reset needs a reset listener.
  • If the parent forgets to update value, the stars don't move.

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.

ts
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
};
The two components' props.

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?

Unicode star characters

Text glyphs: U+2605 (black star) and U+2606 (white star), colored with color.

Pros

  • Zero assets, tiny HTML, follows text color and size.

Cons

  • The shape depends on the font, so it changes across systems.
  • Partial fill needs tricks such as clipping text.
  • Screen readers may say "black star" unless hidden, and a nearby emoji star renders in color and ignores color.

An icon font

A web font whose private-use characters draw stars.

Pros

  • Follows color and font-size like text.

Cons

  • An extra font download; until it loads, users see empty boxes or nothing.
  • Users who override fonts for readability lose the icons.

Inline SVG with currentColor

One star symbol in a sprite, each star a small svg with use. Filled stars use fill: currentColor; empty stars use fill: none and stroke: currentColor.

Pros

  • The same crisp shape everywhere, at any size.
  • Follows the text color, so themes and forced colors work.
  • Partial fill with a clip or gradient.

Cons

  • More markup than a character, so use a sprite: 240 stars that each repeat a 300-byte path add about 72 KB of HTML before compression; with use each star is about 60 bytes.

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.

Design diagram. In the browser: RatingSummary, a display rendered as server HTML, and RatingHistogram, whose bars are links with stars equals n. Below them, the StarRating island with radios, and the Rating store holding the summary and my rating. The user clicks or presses keys on StarRating, which sends a change to the store; the store re-renders the summary and histogram, and passes the settled value to the API client, which keeps one request in flight. On the server side: the CDN serves the page HTML to the browser, the Ratings API receives the PUT from the API client and upserts into the ratings counters, and the counters feed the summary cached at the CDN.BrowserServer sideRatingSummaryserver HTMLRatingHistogramlinks ?stars=nStarRatingisland: radiosRating storesummary, myRatingAPI clientone in flightuserclick, keyschangerendersettledCDNserves page HTMLCountershistogramRatings APIPUT my-ratingupsertsummary
The summary and histogram arrive as server HTML; the store re-renders them after a rating. The store is the single copy of the summary and my rating, so the header, the histogram, and the input never disagree, and the API client is the one place that talks to the server.
PieceResponsibilityWhy the boundary is here
RatingDisplayDraws 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.
RatingSummaryThe 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.
RatingHistogramFive 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 storeHolds 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 clientSends PUT and DELETE for my rating, one request at a time, and handles 429.Keeps network rules out of the components.

The data model

ts
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" };
Client-side entities. The histogram is the source of truth; the average is derived from it.
DataWhere it livesFreshness
RatingSummaryIn 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.
MyRatingFetched per user after hydration, marked Cache-Control: private, no-store.Must be exact for this user; never cached at the CDN.
Hover previewComponent state.Lives for the length of a hover.
Histogram filterThe 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.

Data flow. Reads, on the left, top to bottom: the Counters store sends the histogram to the Page server, which renders the summary into HTML; the CDN caches that HTML for 60 seconds; the browser draws the summary before any JavaScript runs. Writes, on the right, bottom to top: the StarRating island and store send the value up to the API client, which sends PUT to the Ratings API, which upserts the counters. Back down: the Ratings API returns the new summary to the API client, and the store reconciles and re-renders. Across the bottom, the store sends the optimistic summary to the drawn summary.Countershistogram per productPage serverrenders the summaryCDNs-maxage=60Summary in HTMLdrawn before JShistogramHTMLcached HTMLRatings APIvalidate, upsertAPI clientone PUT in flightStarRating, storeoptimistic changevaluePUTsummaryreconcileupsertoptimistic
Reads are shared and cacheable, so they flow through the CDN. Writes are personal: one 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.

The histogram for 1,204 ratings. 5 star: 702 ratings, 58.3 percent, shown as 58. 4 star: 288, 23.9 percent, shown as 24. 3 star: 108, 9.0 percent, shown as 9. 2 star: 42, 3.5 percent, shown as 3. 1 star: 64, 5.3 percent, shown as 5, and this bar is the selected filter. The shown numbers add up to 99. The largest remainder method gives 58, 24, 9, 4 and 5, which add up to 100.5 star702 = 58.3%, shows 584 star288 = 23.9%, shows 243 star108 = 9.0%, shows 92 star42 = 3.5%, shows 31 star64 = 5.3%, shows 5shown: 58 + 24 + 9 + 3 + 5 = 99largest remainder: 58 + 24 + 9 + 4 + 5 = 1001 star: selected,aria-current=true
Each row is one link, like ?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?

POST /ratings that creates a rating

Each call creates a new rating row; the server may deduplicate by user.

Pros

  • Familiar REST shape.
  • Fits products where one user may leave several ratings, for example one per order.

Cons

  • Not idempotent: a retry after a timeout can create a second rating unless you add an idempotency key.
  • Changing your mind needs a different call.

PUT /products/{id}/my-rating with { value }

The URL names the one resource "my rating for this product"; the body is its new value. DELETE on the same URL clears it.

Pros

  • Idempotent: sending the same PUT twice leaves the same state, so retries are safe.
  • Create and update are the same call.
  • The server enforces one rating per user per product by the URL itself.

Cons

  • The server must handle the first PUT as a create, which is a small amount of extra logic.

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.

http
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
Saving and clearing my rating. The response carries the fresh summary.

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.

Sequence diagram of rating a product, with five lifelines: User, StarRating, Store, API client and Server. The user clicks star 4. StarRating calls onChange with 4 on the Store. The Store renders 4 stars and the new average at once. When the value has settled, the Store gives 4 to the API client, which sends PUT with value 4 to the Server. The Server replies 200 with the new summary, and the API client hands it to the Store, which replaces its summary. If the PUT fails: the Server replies 500 or times out, the API client retries the same PUT, and when the retries also fail it reports an error to the Store, which rolls back to 3 stars and shows an error next to the stars.UserStarRatingStoreAPI clientServerclick star 4onChange(4)render 4 nowsettled: 4PUT value 4200, summarynew summaryif the PUT fails:500, timeoutretries failedroll back
The user sees the result in the same frame as the click. The network runs later and once. On success the server's summary wins. On failure the idempotent 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.

State machine for saving my rating. Idle, where the value is saved, goes to Settling on a change. Settling, with a 500 millisecond timer, loops to itself on another change, restarting the timer, and goes to Saving when it has been quiet. Saving, with one PUT out, loops to itself on a change by queueing the newest value; on 200 OK it goes back to Idle with the server summary; when an error outlasts the retries, including a second 429, it goes to Failed, where the old value is shown. From Failed, a retry or a new change goes to Settling.Idlevalue is savedSettling500 ms timerSavingone PUT outFailedold value shownchangechange: restartquietchange: queue200 OKerrorretry
Two loops do the important work. Settling restarts its timer, so a burst of arrow keys sends one value. Saving keeps only the newest value, so two 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. PUT is 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 sessionStorage through 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: DELETE is 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" }) and getByRole("radio", { name: /4 stars/ }) only work if the semantics are right. Then test arrows, Space, reset, required, and that hovering never calls onChange.
  • 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 PUT with 4; a 500 that outlasts the retries rolls back and shows the error; a 429 waits for Retry-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.

MinutesWhat to doWhat to say out loud
0 to 5Ask 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 12The 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 17The 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 22Hover 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 28Saving: 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 30Testing 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?

Think first

A big sale sends 50,000 ratings a minute to the same product. Does the frontend design change?

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, required and reset; JavaScript adds only hover preview and clearing.
  • Keep the hover preview separate from the committed value, ignore hover on touch, and never call onChange from a hover.
  • Draw filled and empty stars as solid versus outline with currentColor, put the focus ring on the label with outline, and fill from the right in RTL.
  • Save with an idempotent PUT on 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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next