intermediateCommon6 min read · Updated Jul 19, 2026

Build a Star Rating Widget (Vanilla JS)

Build a star rating component in vanilla JavaScript — hover preview vs committed state, event delegation, keyboard support, and the radio-group ARIA pattern.


The problem

Build a 5-star rating widget, no framework: hover previews a rating, click commits it, clicking the same star clears it, keyboard works, and a screen reader can use it.

This is the most-assigned small machine-coding widget (Atlassian and Amazon loops love it) because it's graded on exactly the things tutorials skip: the two-state model (hover preview vs committed value), event delegation instead of five listeners, and the part that decides senior-vs-not — keyboard and ARIA. The rendering is trivial; the state discipline isn't.

The state model — get this right and the rest follows

Two independent pieces of state, one render function:

  • value — the committed rating (survives mouse leave).
  • hovered — the preview (0 when the pointer is outside).
  • Displayed stars = hovered || value — one expression encodes the entire UX: preview while hovering, fall back to committed on leave.

Candidates who merge these into one variable end up restoring state in mouseleave handlers and chasing flicker bugs. Separate state + derived render is the whole lesson — it's the memoizeLast/React model in miniature: state changes, render derives.

Implementation

function createStarRating(container, {
  max = 5,
  value = 0,
  onChange = () => {},
} = {}) {
  let committed = value;
  let hovered = 0;
 
  // --- render: build once, then only toggle classes/attributes ---
  container.classList.add("star-rating");
  container.setAttribute("role", "radiogroup");
  container.setAttribute("aria-label", "Rating");
 
  const stars = Array.from({ length: max }, (_, i) => {
    const star = document.createElement("button");
    star.type = "button";                    // never submit a surrounding form
    star.className = "star";
    star.dataset.value = String(i + 1);
    star.setAttribute("role", "radio");
    star.setAttribute("aria-label", `${i + 1} of ${max} stars`);
    star.textContent = "★";
    container.append(star);
    return star;
  });
 
  function render() {
    const shown = hovered || committed;      // the one-expression UX model
    stars.forEach((star, i) => {
      star.classList.toggle("filled", i < shown);
      star.setAttribute("aria-checked", String(i + 1 === committed));
      // Roving tabindex: ONE tab stop for the whole group
      star.tabIndex = i + 1 === (committed || 1) ? 0 : -1;
    });
  }
 
  function commit(next) {
    committed = next === committed ? 0 : next; // click current star = clear
    hovered = 0;
    render();
    onChange(committed);
  }
 
  // --- events: THREE delegated listeners, not 3×max ---
  container.addEventListener("click", (e) => {
    const star = e.target.closest(".star");
    if (star) commit(Number(star.dataset.value));
  });
 
  container.addEventListener("mouseover", (e) => {
    const star = e.target.closest(".star");
    if (star) { hovered = Number(star.dataset.value); render(); }
  });
 
  container.addEventListener("mouseleave", () => {
    hovered = 0;
    render();                                // fall back to committed
  });
 
  container.addEventListener("keydown", (e) => {
    const delta = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key];
    if (delta !== undefined) {
      e.preventDefault();                    // don't scroll the page
      const next = Math.min(max, Math.max(1, (committed || 0) + delta));
      commit(next === committed ? committed : next); // arrows never clear
      stars[next - 1].focus();
    }
  });
 
  render();
 
  return {
    getValue: () => committed,
    setValue: (v) => { committed = Math.min(max, Math.max(0, v)); render(); },
    destroy: () => container.replaceChildren(), // listeners on container die with nodes? No—
    // container listeners persist; a real destroy also removes them (kept simple here;
    // see the teardown note below)
  };
}
.star { background: none; border: 0; font-size: 1.5rem; color: #555; cursor: pointer; }
.star.filled { color: gold; }

Wait — that inline destroy comment is doing real work: replaceChildren removes the stars but the four listeners live on the container, which survives. A correct teardown stores the handler references and removeEventListeners them, or the widget owns (and removes) a wrapper element. Interviewers ask "what leaks?" — the emitter-leak answer applies to DOM widgets identically.

Why each decision earns marks

  • <button role="radio"> elements, not <span>s — buttons are focusable, clickable via keyboard, and announced by default; a <div onclick> widget fails the accessibility grading line before ARIA is even discussed. (type="button" because inside a review form, default type="submit" submits it on every star click — a classic real bug.)
  • The radiogroup/radio ARIA pattern — a rating is a single-select group; screen readers announce "3 of 5 stars, radio button, checked." aria-checked tracks the committed value, never the hover preview (announcing every hover is noise).
  • Roving tabindex — one Tab stop for the whole widget (Tab enters, arrows move inside, Tab leaves) — the standard composite-widget keyboard contract. Setting all stars tabindex="0" makes keyboard users press Tab five times to cross your widget; it's the detail that distinguishes "added ARIA" from "knows the patterns."
  • Delegated mouseover instead of mouseenter per starmouseenter doesn't bubble (the delegation trap); mouseover does. This widget is the delegation article's exam question.
  • Click-again-to-clear — ratings need an undo path; without it a misclick is permanent. Product judgment, one line of code.

Edge cases interviewers probe

  • Hover then leave without clicking — preview must vanish, committed survives: the two-state model's proof.
  • Half stars — the follow-up. Options: mousemove + offsetX < width/2 for pointer precision, or double the radio count (10 half-star buttons, visually overlapped) which keeps keyboard/ARIA coherent for free. The second is the better answer because of accessibility — a genuinely nice design fork.
  • Form integration — mirror committed into a hidden <input name="rating"> so plain form submission works; the widget stays progressive-enhancement-friendly.
  • Multiple instances per page — everything is closure-scoped per container (the module pattern); shared/global state is the bug to avoid. Interviewers instantiate two and click both.
  • RTL locales — arrow directions should flip with dir="rtl"; naming it is plenty.

Common mistakes

  • One state variable for hover and committed (flicker + restore hacks).
  • Listeners per star (5 × 3 bindings), or mouseenter delegation that silently never fires.
  • <span> stars with click handlers — unfocusable, unannounced, auto-fail on a11y.
  • Missing type="button" inside forms.
  • destroy that removes nodes but leaves container listeners (the leak above).
  • Unicode-only "styling" (★ vs ☆ swaps) that breaks when the follow-up asks for halves — class-toggled fills survive requirements changes.

Follow-up questions

  • "Make it read-only for display contexts." — an interactive: false option that skips listeners and sets role="img" + aria-label="4.5 out of 5 stars" — the display case is a different ARIA pattern, and knowing that is the answer.
  • "Half-star precision." — the design fork above; walk both options and pick with a reason.
  • "Wrap it as a React component."useState for both states, the derived-render expression unchanged; the controlled/uncontrolled prop question (value vs defaultValue) is where the interview goes next — mirror the debounce-in-React identity discipline for onChange.
  • "Persist and sync the rating." — optimistic UI: commit visually, PATCH behind, roll back on failure (retry with backoff if transient) — three sentences of systems glue that upgrade the widget answer.
  • "Animate the fill?" — CSS transitions on color/transform with prefers-reduced-motion respected — one media query that reads as production experience.

  • A concurrency-limited scheduler with priorities and cancellation — the pool question upgraded to the API-design round.

  • IntersectionObserver, in-flight guards, end-of-data states, and the sentinel pattern — the pagination question every feed team asks.

  • Debounce, cancellation, and stale-response guards composed into a search box that never shows the wrong results.

  • LRU Cache

    advanced

    O(1) get/put with least-recently-used eviction — the Map insertion-order trick, and the linked-list version interviewers ask about.