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
- Event delegation â Single listener on parent vs per-star listeners
- Accessibility â
role="radiogroup",aria-checked, keyboard navigation with roving tabindex - Separation of state â
hoverRatingvscurrentRatingas distinct concerns - Performance â Not re-rendering the entire component on hover (React.memo individual stars)