How would you design a star rating component with half-stars in React?

Intermediate15 min interview
Skills tested:
Controlled and Uncontrolled ComponentsMouse and Pointer Event HandlingKeyboard AccessibilitySVG Rendering TechniquesState Management PatternsReusable Component Design

Advertisement

🧩 Scenario

You need a reusable rating component similar to Airbnb or Amazon that supports: - Half-star ratings - Hover preview before selection - Keyboard navigation and accessibility - Controlled and uncontrolled usage - Custom icons and sizing - Read-only display mode The component should be flexible enough for design-system use while remaining performant when rendered across large product lists.

🧠 Architecture Walkthrough

Separate Hover State Prevents Value Corruption

The most common mistake in rating component implementations is mutating currentValue during hover. The reasoning seems intuitive — highlight stars as the mouse moves over them. But if hover updates the committed value, then every mouse movement permanently changes the rating.

The fix is a dedicated hoverValue state that is null when the mouse is not over the component, and a number when it is. The displayValue expression resolves which to use: hoverValue !== null ? hoverValue : currentValue. The component always renders from displayValue, but only currentValue persists after the mouse leaves.

The onMouseLeave handler on the container div resets hoverValue to null, snapping the visual back to the committed value. This two-state pattern is reusable across any "preview on hover, commit on click" interaction range sliders, color pickers, and tag selectors follow the same model.

Controlled and Uncontrolled Modes via Presence Check

The component detects its mode with a single expression: const currentValue = value !== undefined ? value : internalValue. If a value prop is passed, the component is controlled it reads from value and calls onChange to request updates, but never updates its own internalValue.

If no value prop is passed, it is uncontrolled it owns internalValue and updates it directly. The click handler respects this: if (onChange) { onChange(newValue) } else { setInternalValue(newValue) }. This is exactly the pattern React itself uses for <input> the presence of a value prop determines the mode.

The subtle trap is value={undefined} passing undefined explicitly behaves the same as omitting the prop entirely, because undefined !== undefined is false. A stricter implementation would check 'value' in props but the !== undefined check covers the common case correctly.

SVG LinearGradient for Pixel-Perfect Half Stars

Half-star rendering uses an SVG linearGradient with id="halfFill". The gradient stops at 50%: the left half gets #fbbf24 (yellow) and the right half gets #e5e7eb (gray). The star path then uses fill={half ? 'url(#halfFill)' : filled ? '#fbbf24' : '#e5e7eb'}.

This approach produces pixel-perfect half-star fills that scale cleanly at any size because SVG is vector-based no image sprites, no CSS clipping hacks, no overflow: hidden gymnastics. The important limitation is that the gradient id is hardcoded as "halfFill", which causes a conflict if two StarRating components are rendered on the same page.

Both will define a <defs> block with id="halfFill", the second definition will shadow the first in the DOM, and the first component's half stars may render incorrectly. The fix in a production component would be to generate a unique gradient ID per instance using useId() (React 18) or useRef with a counter.

💡 Key Code Explained

const currentValue = value !== undefined ? value : internalValue;
const displayValue = hoverValue !== null ? hoverValue : currentValue;

These two lines encode the entire state model. currentValue picks the source of truth based on whether the component is controlled or uncontrolled. displayValue layers hover preview on top of whatever the committed value is. Everything rendered which stars are filled, which are half, the aria attributes reads from displayValue.

A junior developer might think one state variable is enough, but collapsing hover and committed value into the same variable makes the component unusable: every mouse movement would permanently update the rating, and mousing away would leave the last hovered value committed.

const handleStarClick = (starIndex, event) => {
  if (readonly) return;

  const rect = event.currentTarget.getBoundingClientRect();
  const x = event.clientX - rect.left;
  const isHalf = x < rect.width / 2;
  const newValue = starIndex + (isHalf ? 0.5 : 1);

  if (onChange) {
    onChange(newValue);
  } else {
    setInternalValue(newValue);
  }
};

The half-star detection is the mechanical heart of the component. event.currentTarget.getBoundingClientRect() returns the star button's bounding box in viewport coordinates. event.clientX - rect.left converts the mouse's viewport X position to a position relative to the star's left edge. If that position is less than half the star's width, the click lands on the left half a half-star.

starIndex + (isHalf ? 0.5 : 1) maps the zero-based index and half-ness to a rating value: clicking the right side of star index 2 (the third star) gives 2 + 1 = 3.0, clicking the left side gives 2 + 0.5 = 2.5. This math works for any total value without modification, which is why the demo can render both a 5-star and a 10-star variant from the same component.

⚖️ Tradeoffs

ApproachProCon
SVG linearGradient for half fillPixel-perfect at any size; no image assets neededid collision if multiple instances rendered; requires unique ID generation per instance
CSS overflow: hidden with two overlapping iconsNo SVG required; works with icon fontsRequires precise positioning; breaks with non-square icons or custom sizes
Two separate half-star SVG pathsNo gradient needed; easy to style each half independentlyMore complex SVG authoring; icon must be split into two semantic halves
Image sprite for each fill stateZero rendering complexity once assets existNot scalable; every size change requires new assets; 0.5-step granularity limits

🎯 What Interviewers Actually Check

  • Whether you maintain separate hoverValue and currentValue conflating them is the single most common rating component bug
  • Whether you implement both controlled and uncontrolled modes a component that only works in one mode is incomplete for a reusable component library
  • Whether you recognize the id="halfFill" gradient collision bug when multiple instances are on the page most candidates miss this entirely
  • Whether you use event.currentTarget (the button element) rather than event.target (which could be the SVG path inside the button) in the bounding rect calculation
  • Whether you handle keyboard navigation with ArrowLeft/ArrowRight at 0.5 increments and correctly call event.preventDefault() to stop the page from scrolling

❓ Follow-Up Questions

  1. How would you fix the id="halfFill" gradient collision so that multiple StarRating instances on the same page all render correctly?
  2. The keyboard handler uses event.preventDefault() which specific browser default behavior is being prevented, and what breaks if you remove that call?
  3. How would you extend this component to support touch events on mobile, where onMouseMove and onClick do not fire in the same way?
  4. If you were rendering 500 StarRating components in a product listing, what would you do differently to prevent performance issues from all those SVG gradient definitions?
  5. Your design system team wants to replace the yellow star with a custom icon passed as a prop. How do you restructure StarIcon to accept a render prop or component prop without breaking the half-fill gradient logic?

🎮 Live Demo

📝 Summary

The star rating component's design rests on three independent decisions that each solve a distinct problem. The two-state model currentValue for committed ratings and hoverValue for hover preview ensures that mouse movement never accidentally commits a value.

The controlled/uncontrolled duality, implemented via a single value !== undefined check, makes the component usable both inside form libraries and as a standalone widget without requiring separate implementations. The SVG linearGradient gives half-star fills that are pixel-perfect at any size without assets, though it introduces an id collision risk that every production implementation must address with per-instance unique gradient IDs.

Taken together, these decisions produce a component that behaves correctly across all four of its usage modes: controlled, uncontrolled, readonly, and keyboard-only.

Frequently Asked Questions

How do you detect half-star clicks?

Measure mouse position relative to star width. If x < 50%, treat as half.

Should the component be controlled or uncontrolled?

Support both — accept `value` + `onChange`, and also allow internal state.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement