Star Rating
Hover along the stars and they light up to that point. Click or tap and they fill one after another, each one bouncing, while the chosen star throws out a few sparks and the word underneath rolls over to match.
How to use it
- Paste the snippet just before
</head>. - Add an empty element with
data-star-ratingand anaria-label: the stars are built inside it. - Size it with
font-size: each star is 1.9em.
Options
Add any of these attributes to the same element.
data-max | Number of stars. Default 5. |
data-value | The starting rating. Default 0. |
data-words | One word per star, comma-separated, shown under the stars. Default Awful,Poor,Okay,Good,Great!. none hides them. |
data-prompt | Shown before anyone rates. Default Tap a star to rate. |
data-name | Adds a hidden input with this name, so the rating posts with a form. |
data-color | Star colour. Default #f5b301. |
data-autoplay | It sweeps along and rates by itself while in view, and stops as soon as you use it. |
The stars are a radio group, so arrow keys change the rating and screen readers hear “4 stars, Good”. Listen for the rate event: e.detail.value is the rating.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Star Rating: a row of stars that fill with a pop as you rate. Hover to
// preview, and the stars light up to that point. Click (or tap) and they
// fill one after another, each one bouncing, the chosen star throws out
// a few sparks and the word under the stars rolls over to match.
// Usage: <div data-star-rating aria-label="Rate this recipe"></div>
// Optional: data-max (number of stars, default 5), data-value (starting
// rating, default 0), data-words (one word per star, comma-separated,
// default "Awful,Poor,Okay,Good,Great!"; "none" hides them), data-prompt
// (shown before rating, default "Tap a star to rate"), data-color (star
// colour, default #f5b301), data-name (adds a hidden input with this name
// so the rating posts with a form), data-autoplay (it sweeps and rates by
// itself while in view; it stops as soon as you use it).
// It's a radio group: arrow keys move the rating.
// Listen for the "rate" event: e.detail.value is the rating.
var DEFAULTS = { max: 5, value: 0, words: 'Awful,Poor,Okay,Good,Great!', prompt: 'Tap a star to rate', color: '#f5b301' };
var STAR = 'M12 2.8l2.75 5.6 6.15.9-4.45 4.35 1.05 6.12L12 16.88l-5.5 2.9 1.05-6.12L3.1 9.3l6.15-.9z';
var css =
'.srt{display:inline-flex;flex-direction:column;align-items:center;gap:.5em;-webkit-tap-highlight-color:transparent}' +
'.srt-row{display:flex;gap:.18em}' +
'.srt-star{position:relative;display:grid;place-items:center;width:1.9em;height:1.9em;margin:0;padding:0;border:0;background:none;color:inherit;' +
'font:inherit;cursor:pointer;touch-action:manipulation}' +
'.srt-star svg{width:100%;height:100%;overflow:visible}' +
'.srt-back{fill:#e4e4e7;stroke:#e4e4e7;stroke-width:1.6;stroke-linejoin:round}' +
'.srt-fill{fill:var(--srt-color);stroke:var(--srt-color);stroke-width:1.6;stroke-linejoin:round;transform-box:fill-box;transform-origin:50% 55%;transform:scale(0)}' +
'.srt-star:focus-visible{outline:2px solid var(--srt-color);outline-offset:1px;border-radius:.4em}' +
'.srt-spark{position:absolute;left:50%;top:50%;width:.22em;height:.22em;margin:-.11em 0 0 -.11em;border-radius:50%;background:var(--srt-color);pointer-events:none}' +
'.srt-word{position:relative;display:block;align-self:stretch;height:1.4em;line-height:1.4em;overflow:hidden;font-size:.62em;font-weight:600;color:#71717a;white-space:nowrap;text-align:center}' +
'.srt-word span{display:block}' +
'.srt-rated .srt-word{color:#27272a}' +
'.srt-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function setup(el) {
var d = el.dataset;
var mock = !!el.closest('[aria-hidden="true"]');
var max = Math.max(1, parseInt(d.max, 10) || DEFAULTS.max);
var rating = Math.max(0, Math.min(max, parseInt(d.value, 10) || DEFAULTS.value));
var words = d.words === 'none' ? null : (d.words || (max === 5 ? DEFAULTS.words : '')).split(',').map(function (w) { return w.trim(); });
if (words && words.length < max) words = null;
var prompt = d.prompt || DEFAULTS.prompt;
var label = el.getAttribute('aria-label') || 'Rating';
el.classList.add('srt');
el.style.setProperty('--srt-color', d.color || DEFAULTS.color);
el.textContent = '';
var row = document.createElement('div');
row.className = 'srt-row';
el.appendChild(row);
if (!mock) { row.setAttribute('role', 'radiogroup'); row.setAttribute('aria-label', label); el.removeAttribute('aria-label'); }
var stars = [];
for (var i = 1; i <= max; i++) {
var s = document.createElement(mock ? 'span' : 'button');
s.className = 'srt-star';
s.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path class="srt-back" d="' + STAR + '"/><path class="srt-fill" d="' + STAR + '"/></svg>';
if (!mock) {
s.type = 'button';
s.setAttribute('role', 'radio');
s.setAttribute('aria-label', i + (i === 1 ? ' star' : ' stars') + (words ? ', ' + words[i - 1] : ''));
}
row.appendChild(s);
stars.push({ el: s, fill: s.querySelector('.srt-fill'), shown: 0, dim: false });
}
var word = null;
if (words) {
word = document.createElement('span');
word.className = 'srt-word';
word.setAttribute('aria-hidden', 'true');
word.innerHTML = '<span></span>';
el.appendChild(word);
}
var hidden = null;
if (!mock && d.name) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = d.name;
el.appendChild(hidden);
}
var wordNow = null;
function setWord(text, dir) {
if (!word || text === wordNow) return;
wordNow = text;
// Drop words still rolling out from earlier changes.
while (word.children.length > 1) word.removeChild(word.firstChild);
var old = word.firstChild, next = document.createElement('span');
anime.utils.remove(old);
old.style.position = '';
next.textContent = text;
if (reduced() || !old.textContent) { word.replaceChild(next, old); return; }
next.style.position = 'absolute';
next.style.left = next.style.right = '0';
next.style.top = '0';
word.appendChild(next);
anime.animate(old, { y: ['0%', dir < 0 ? '100%' : '-100%'], opacity: 0, duration: 260, ease: 'out(2)', onComplete: function () { old.remove(); next.style.position = ''; } });
anime.animate(next, { y: [dir < 0 ? '-100%' : '100%', '0%'], opacity: [0, 1], duration: 300, ease: 'out(3)' });
}
// Show stars up to `level`; the ones past the real rating are paler.
var hover = 0;
function render() {
var level = hover || rating;
stars.forEach(function (st, k) {
var want = k < level ? 1 : 0, dim = hover > 0 && k >= rating;
if (dim !== st.dim) { st.dim = dim; st.fill.style.opacity = dim ? 0.5 : 1; }
if (want === st.shown) return;
st.shown = want;
if (reduced()) { anime.utils.set(st.fill, { scale: want }); return; }
anime.utils.remove(st.fill);
anime.animate(st.fill, want ? { scale: 1, duration: 240, ease: 'out(3)' } : { scale: 0, duration: 200, delay: (max - k) * 25, ease: 'in(2)' });
});
el.classList.toggle('srt-rated', !hover && rating > 0);
if (word) setWord(level ? words[level - 1] : prompt, level >= (wordLevel || 0) ? 1 : -1);
wordLevel = level;
if (!mock) stars.forEach(function (st, k) {
st.el.setAttribute('aria-checked', k + 1 === rating);
st.el.tabIndex = k + 1 === (rating || 1) ? 0 : -1;
});
}
var wordLevel = 0;
function sparks(st) {
var n = 8;
for (var j = 0; j < n; j++) {
var p = document.createElement('span');
p.className = 'srt-spark';
st.el.appendChild(p);
var a = (j / n) * Math.PI * 2 + 0.2, r = 1.25 + Math.random() * 0.35;
anime.animate(p, {
x: ['0em', Math.cos(a) * r + 'em'], y: ['0em', Math.sin(a) * r + 'em'], scale: [1.2, 0], duration: 560, delay: 60, ease: 'out(3)',
onComplete: (function (q) { return function () { q.remove(); }; })(p)
});
}
}
function rate(v) {
v = Math.max(0, Math.min(max, v));
var prev = rating;
rating = v;
hover = 0;
if (hidden) hidden.value = v || '';
if (reduced()) { render(); return; }
// Re-fill from the first star so each one pops in turn.
stars.forEach(function (st, k) {
if (k >= v) return;
anime.utils.remove([st.el, st.fill]);
st.shown = 1;
st.dim = false;
st.fill.style.opacity = 1;
var delay = k * 70;
anime.animate(st.fill, { scale: k < prev ? [1, 1] : [0, 1], duration: 300, delay: delay, ease: 'out(3)' });
anime.animate(st.el, { scale: [1, 1.32, 1], rotate: [0, k % 2 ? 10 : -10, 0], duration: 520, delay: delay, ease: 'out(3)' });
});
render();
if (v) setTimeout(function () { sparks(stars[v - 1]); }, (v - 1) * 70 + 120);
if (v !== prev) el.dispatchEvent(new CustomEvent('rate', { bubbles: true, detail: { value: v } }));
}
function preview(v) { if (v !== hover) { hover = v; render(); } }
render();
if (!mock) {
stars.forEach(function (st, k) {
st.el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { stop(); preview(k + 1); } });
st.el.addEventListener('click', function () { stop(); rate(k + 1); });
});
row.addEventListener('pointerleave', function () { preview(0); });
row.addEventListener('pointerdown', stop);
row.addEventListener('keydown', function (e) {
var dir = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key];
if (!dir) return;
e.preventDefault();
stop();
var v = Math.max(1, Math.min(max, rating + dir));
if (v === rating) return;
rate(v);
stars[v - 1].el.focus();
});
}
// Autoplay: sweep along the row like a pointer, then rate.
var timer = null, stopped = false, picks = [4, 5, 3], pick = 0;
function stop() { if (stopped) return; stopped = true; clearTimeout(timer); preview(0); }
function cycle() {
if (stopped) return;
var t = Math.min(max, picks[pick++ % picks.length]), k = 1;
var sweep = function () {
if (stopped) return;
preview(k);
if (k < t) { k++; timer = setTimeout(sweep, 120); return; }
timer = setTimeout(function () {
rate(t);
timer = setTimeout(function () {
rate(0);
timer = setTimeout(cycle, 900);
}, 2300);
}, 260);
};
sweep();
}
if (on(d.autoplay) && !reduced()) {
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (stopped) return;
if (entries[0].isIntersecting) timer = setTimeout(cycle, 600);
else { hover = 0; rating = 0; render(); }
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-star-rating]').forEach(function (el) {
if (el._srt) return;
el._srt = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-star-rating data-name="rating" aria-label="Rate this recipe"></div>