Fossils🛠ïļ Machine CodingBuild a Star Rating Component
ðŸĢHatchlingMachine CodingUI ComponentAccessibility

Build a Star Rating Component

A star rating tests DOM manipulation, event handling, accessibility, and component state management. Interviewers look for hover preview, keyboard support, and half-star precision.

Build a Star Rating Component

Interview Question: "Build an interactive star rating component with hover preview, click to select, and keyboard navigation."

Requirements

  • Display N stars (default 5)
  • Hover previews the potential rating
  • Click selects the rating
  • Support half-star ratings (optional)
  • Fully keyboard accessible (arrow keys, Enter)
  • Screen reader announces the selected rating

Vanilla JavaScript Implementation

<div class="star-rating" role="radiogroup" aria-label="Rating">
</div>
function createStarRating(container, { maxStars = 5, onChange } = {}) {
  let currentRating = 0;
  let hoverRating = 0;
 
  function render() {
    container.innerHTML = '';
    for (let i = 1; i <= maxStars; i++) {
      const star = document.createElement('button');
      star.className = 'star';
      star.setAttribute('role', 'radio');
      star.setAttribute('aria-checked', String(i <= currentRating));
      star.setAttribute('aria-label', `${i} star${i > 1 ? 's' : ''}`);
      star.setAttribute('tabindex', i === Math.max(currentRating, 1) ? '0' : '-1');
      star.textContent = i <= (hoverRating || currentRating) ? '★' : '☆';
      star.dataset.value = String(i);
      container.appendChild(star);
    }
  }
 
  container.addEventListener('mouseover', (e) => {
    const star = e.target.closest('.star');
    if (!star) return;
    hoverRating = Number(star.dataset.value);
    render();
  });
 
  container.addEventListener('mouseleave', () => {
    hoverRating = 0;
    render();
  });
 
  container.addEventListener('click', (e) => {
    const star = e.target.closest('.star');
    if (!star) return;
    currentRating = Number(star.dataset.value);
    hoverRating = 0;
    onChange?.(currentRating);
    render();
  });
 
  container.addEventListener('keydown', (e) => {
    const star = e.target.closest('.star');
    if (!star) return;
    const value = Number(star.dataset.value);
 
    if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
      e.preventDefault();
      const next = Math.min(value + 1, maxStars);
      currentRating = next;
      onChange?.(currentRating);
      render();
      container.querySelector(`[data-value="${next}"]`)?.focus();
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
      e.preventDefault();
      const prev = Math.max(value - 1, 1);
      currentRating = prev;
      onChange?.(currentRating);
      render();
      container.querySelector(`[data-value="${prev}"]`)?.focus();
    }
  });
 
  render();
  return { getRating: () => currentRating };
}

React Implementation

function StarRating({
  maxStars = 5,
  value = 0,
  onChange,
}: {
  maxStars?: number;
  value?: number;
  onChange?: (rating: number) => void;
}) {
  const [hoverRating, setHoverRating] = useState(0);
 
  return (
    <div
      role="radiogroup"
      aria-label="Rating"
      onMouseLeave={() => setHoverRating(0)}
    >
      {Array.from({ length: maxStars }, (_, i) => i + 1).map((star) => (
        <button
          key={star}
          role="radio"
          aria-checked={star <= value}
          aria-label={`${star} star${star > 1 ? 's' : ''}`}
          className={star <= (hoverRating || value) ? 'star filled' : 'star'}
          onMouseEnter={() => setHoverRating(star)}
          onClick={() => onChange?.(star)}
        >
          {star <= (hoverRating || value) ? '★' : '☆'}
        </button>
      ))}
    </div>
  );
}

CSS

.star-rating { display: flex; gap: 4px; }
 
.star {
  background: none;
  border: none;
  font-size: 2rem;
  cursor: pointer;
  color: #ccc;
  transition: color 150ms, transform 150ms;
  padding: 2px;
}
 
.star.filled, .star:hover { color: #f59e0b; }
.star:hover { transform: scale(1.1); }
.star:focus-visible { outline: 2px solid #3b82f6; border-radius: 4px; }

Senior-Level Enhancements

  • Half-star support: Track mouse position within each star's bounding box
  • Unselect: Clicking the same star clears the rating
  • Read-only mode: Display without interaction (product reviews)
  • Animated fill: CSS transition on SVG fill for smooth visual feedback
  • Form integration: Hidden <input> for native form submission

What Interviewers Look For

  1. Event delegation — Single listener on parent vs per-star listeners
  2. Accessibility — role="radiogroup", aria-checked, keyboard navigation with roving tabindex
  3. Separation of state — hoverRating vs currentRating as distinct concerns
  4. Performance — Not re-rendering the entire component on hover (React.memo individual stars)