ESC

Type to search the knowledge base.

Star Rating Input

Machine-coding brief for star rating — hover preview, keyboard, half stars optional, and radiogroup a11y pattern.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • a11y

Problem statement

Build a star rating input: user chooses 1…N stars (usually 5). Support controlled value, hover preview, and full keyboard use. Interviewers score the radiogroup mental model and hover vs committed value split.

Requirements

Must have

  • Display max stars (default 5)
  • Controlled value + onChange (0 = unset or 1…max)
  • Click sets rating
  • Hover previews rating without committing
  • Keyboard: arrows change value; Home/End
  • Accessible name and current value

Should have

  • Read-only display mode
  • Clear rating control
  • Labels (“3 out of 5”)

Nice to have

  • Half-star precision
  • Custom icons
  • Animate on select

Planning (5 minutes out loud)

  1. value vs hoverValue
  2. Radiogroup of radio buttons or slider pattern — APG has rating as radio often
  3. MVP — click + hover + keyboard on group
  4. Don’t use only CSS checkbox hacks without a11y story
  5. Half stars only if time — doubles hit targets

Architecture

StarRating
├── StarButton × max
└── sr-only status

API

type StarRatingProps = {
  value: number; // 0..max
  onChange: (value: number) => void;
  max?: number;
  readOnly?: boolean;
  label?: string;
  id?: string;
};

Implementation sketch

function StarRating({
  value,
  onChange,
  max = 5,
  readOnly = false,
  label = "Rating",
}: StarRatingProps) {
  const [hover, setHover] = useState<number | null>(null);
  const display = hover ?? value;

  function set(n: number) {
    if (readOnly) return;
    onChange(n);
  }

  return (
    <div
      role="radiogroup"
      aria-label={label}
      className="stars"
      onMouseLeave={() => setHover(null)}
      onKeyDown={(e) => {
        if (readOnly) return;
        if (e.key === "ArrowRight" || e.key === "ArrowUp") {
          e.preventDefault();
          set(Math.min(max, (value || 0) + 1));
        }
        if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
          e.preventDefault();
          set(Math.max(1, value - 1 || 1));
        }
        if (e.key === "Home") {
          e.preventDefault();
          set(1);
        }
        if (e.key === "End") {
          e.preventDefault();
          set(max);
        }
      }}
    >
      {Array.from({ length: max }, (_, i) => {
        const n = i + 1;
        const checked = value === n;
        const filled = n <= display;
        return (
          <button
            key={n}
            type="button"
            role="radio"
            aria-checked={checked}
            aria-label={`${n} star${n > 1 ? "s" : ""}`}
            tabIndex={readOnly ? -1 : value === n || (value === 0 && n === 1) ? 0 : -1}
            disabled={readOnly}
            className={filled ? "star filled" : "star"}
            onMouseEnter={() => setHover(n)}
            onFocus={() => setHover(n)}
            onClick={() => set(n)}
          >
            <span aria-hidden>★</span>
          </button>
        );
      })}
      <span className="sr-only" aria-live="polite">
        {value > 0 ? `${value} out of ${max}` : "No rating"}
      </span>
    </div>
  );
}

Roving tabindex: when value changes, ensure the selected radio is the tab stop.

Half stars (extension)

Use a single star with two hit zones or track pointer X within star width:

function precisionFromEvent(el: HTMLElement, clientX: number, base: number) {
  const rect = el.getBoundingClientRect();
  const mid = rect.left + rect.width / 2;
  return clientX < mid ? base - 0.5 : base;
}

Store halves as 0.5 steps; still expose clear textual value.

Accessibility essentials

Piece Approach
Group role="radiogroup" + label
Each star role="radio" + aria-checked
Keyboard arrows per radiogroup pattern
Live text “3 out of 5”
Read-only present as text or aria-readonly group

Visual fill must not be the only indicator — name includes the number.

Performance notes

  • Trivial; no memo required
  • Avoid heavy SVGs recreated each render without need

Footguns

  1. Hover sticks after mouse leave
  2. Click sets hover value but forgets controlled commit
  3. tabIndex all 0 — messy tab stops; use roving
  4. Using emoji alone without accessible names
  5. 0 stars selected with no radio checked — define initial tab stop

Interview out-loud answer

Star rating is a radiogroup: one value 1…max, hover preview is ephemeral UI state. Buttons are radios with roving tabindex and arrow key support. Read-only mode is display-only. Half-stars are pointer-X precision if product needs them. I’d keep value controlled for form libraries.

Further reading