javascript 55 lines · 7 steps

Building an accessible star-rating widget

A vanilla-JS class renders interactive stars and keeps hover, selection, and ARIA state in sync through a single paint method.

Explained by highlit
1class StarRating {
2 constructor(container, { max = 5, value = 0, onChange } = {}) {
3 this.container = container;
4 this.max = max;
5 this.value = value;
6 this.hovered = 0;
7 this.onChange = onChange;
8 this.render();
9 this.bindEvents();
10 }
11 
12 render() {
13 this.container.classList.add('star-rating');
14 this.container.innerHTML = Array.from({ length: this.max }, (_, i) =>
15 `<span class="star" data-index="${i + 1}" role="radio" tabindex="0" aria-label="${i + 1} star">\u2605</span>`
16 ).join('');
17 this.stars = [...this.container.querySelectorAll('.star')];
18 this.paint();
19 }
20 
21 bindEvents() {
22 this.stars.forEach((star) => {
23 const index = Number(star.dataset.index);
24 star.addEventListener('mouseenter', () => {
25 this.hovered = index;
26 this.paint();
27 });
28 star.addEventListener('click', () => this.select(index));
29 star.addEventListener('keydown', (e) => {
30 if (e.key === 'Enter' || e.key === ' ') {
31 e.preventDefault();
32 this.select(index);
33 }
34 });
35 });
36 this.container.addEventListener('mouseleave', () => {
37 this.hovered = 0;
38 this.paint();
39 });
40 }
41 
42 select(index) {
43 this.value = this.value === index ? index - 1 : index;
44 this.paint();
45 this.onChange?.(this.value);
46 }
47 
48 paint() {
49 const active = this.hovered || this.value;
50 this.stars.forEach((star, i) => {
51 star.classList.toggle('filled', i < active);
52 star.setAttribute('aria-checked', String(i + 1 === this.value));
53 });
54 }
55}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Routing every visual update through one paint method keeps hover and selection state consistent without scattered DOM writes.
  2. 2Building interactive DOM from a data array lets index metadata drive both rendering and event logic.
  3. 3Adding role, tabindex, and aria-checked makes a custom widget usable by keyboard and screen readers.

Related explainers

javascript
const form = document.querySelector('#signup-form');
const password = form.querySelector('#password');
const confirm = form.querySelector('#confirm-password');
const submit = form.querySelector('button[type="submit"]');

Live password-match validation in the DOM

form-validation dom-events accessibility
Intermediate 6 steps
javascript
const express = require('express');
const EventEmitter = require('events');
 
const router = express.Router();

Server-Sent Events with Express

server-sent-events streaming event-emitter
Advanced 8 steps
javascript
import { useState, useEffect, useCallback } from 'react';
 
export function useCountdown(initialSeconds) {
  const [secondsLeft, setSecondsLeft] = useState(initialSeconds);

Building a useCountdown hook in React

custom-hooks state-management side-effects
Intermediate 8 steps
typescript
import { Injectable, signal, computed } from '@angular/core';
 
export type ToastKind = 'success' | 'error' | 'info' | 'warning';
 

Building a signal-based toast service in Angular

signals state-management dependency-injection
Intermediate 8 steps
typescript
type LazyImageOptions = {
  rootMargin?: string;
  loadedClass?: string;
};

Lazy-loading images with IntersectionObserver

intersectionobserver lazy-loading performance
Intermediate 7 steps
javascript
import { useState, useRef } from "react";
 
export function TagInput({ initialTags = [], onChange }) {
  const [tags, setTags] = useState(initialTags);

Building a tag input in React

controlled-inputs state-management keyboard-handling
Intermediate 8 steps

Share this explainer

Here's the card — post it anywhere.

Building an accessible star-rating widget — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code