A row of stars looks like the most trivial component you could build — five clickable icons, count how many are gold, done. Then you actually build one, and you discover it is quietly doing three hard things at once: distinguishing a preview from a real choice, rendering fractions, and being usable without a mouse. I built a full star-rating input in vanilla JS, and every one of those turned out to fall out of one small idea.
Two numbers, not one
This is the mental model that separates a real rating widget from a naive one. There are two numbers:
let value = 0; // committed rating — what you'd save
let hover = null; // transient preview; null = not previewing
const displayValue = () => hover !== null ? hover : value;
value is what the user has actually chosen. hover is a throwaway that shows what they would get if they clicked right now. The stars always paint displayValue() — the preview if one is in flight, otherwise the committed value. Hovering never touches value; only a click or a keypress commits.
The classic beginner bug is writing the hover straight into value on mouseover, which means merely gliding your mouse across the stars silently changes the rating. The preview/commit split is exactly what prevents that — and it is also why a keypress clears hover first: a key is a commit, so there is no preview to keep.
Half a star is a clipped layer
You cannot paint half a glyph, so each star is two stacked copies: a grey empty star underneath and a gold filled star on top, where the gold layer is a clipping window with a variable width.
.star { position:relative; width:46px; height:46px; }
.star-fill { position:absolute; inset:0 auto 0 0; width:0; overflow:hidden; }
.star-fill svg { width:46px; } /* fixed → the window clips, glyph never shrinks */
Rendering is then a pure projection of one number into N widths. For star i (1-based) the filled portion is displayValue − (i−1), clamped to 0–1:
starEls.forEach((star, idx) => {
const portion = Math.min(1, Math.max(0, dv - idx)); // idx = i-1
star.querySelector(".star-fill").style.width = portion * 100 + "%";
});
A value of 3.5 gives stars one to three a portion of 1, star four exactly 0.5, star five 0. Because the inner SVG keeps a fixed pixel width while only the wrapper narrows, the star clips like a curtain being drawn instead of squashing. One mechanism shows 0%, 50%, 100%, or any fraction — which is why a server-provided 4.3 average "just works" with no extra code.
Which half? Measure the pointer
Hover is driven by mousemove, not mouseenter, because whole-versus-half depends on where inside a star the pointer is:
star.addEventListener("mousemove", e => {
const r = star.getBoundingClientRect();
const leftHalf = step === 0.5 && (e.clientX - r.left) < r.width/2;
hover = i - (leftHalf ? 0.5 : 0);
render(); // preview only — value untouched
});
strip.addEventListener("mouseleave", () => { hover = null; render(); });
There is no separate "half star" element; it is just a decision about which fraction the current pointer maps to. The one detail people forget is that mouseleave on the whole strip, which resets the preview so the committed value snaps back instead of the display getting stuck.
A click promotes the preview into value — and clicking the value you already have clears it back to zero, giving an obvious undo for a mis-tap without a separate button.
It is really a slider
A rating is a value on a bounded, ordered scale, which is exactly what a slider is. So the strip is a single focusable element that adopts the slider key model:
strip.addEventListener("keydown", e => {
let v = value;
if (e.key==="ArrowRight"||e.key==="ArrowUp") v += step;
else if (e.key==="ArrowLeft"||e.key==="ArrowDown") v -= step;
else if (e.key==="Home") v = 0;
else if (e.key==="End") v = MAX;
else return;
e.preventDefault();
hover = null; // a key commits
value = Math.min(MAX, Math.max(0, v)); // clamp
announce(); render();
});
The beauty is that "one step" is your half-star setting — with step at 0.5 the arrows move in halves, at 1 in wholes, and you wrote no special half-star keyboard code.
Make it speak
Colour cannot carry a rating alone: a colour-blind user may not tell gold from grey, and a screen-reader user sees no colour at all. So the strip gets role="slider" with min/max/now, and the load-bearing attribute is aria-valuetext — a bare "3.5" is meaningless, but "3.5 out of 5 stars, Good" is clear. Every commit updates it and mirrors the message into a polite live region.
The same render function, fed a number and a read-only flag, doubles as the display widget for an aggregate score. Model the state as a plain bounded number and the hover, half-stars, keyboard, and read-only mode all fall out of it instead of feeling bolted on.
Hover, click, toggle half-stars, and try driving it with just the arrow keys:
https://dev48v.infy.uk/design/day34-rating-stars.html
Top comments (0)