← Forms

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

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-star-rating and an aria-label: the stars are built inside it.
  3. Size it with font-size: each star is 1.9em.

Options

Add any of these attributes to the same element.

data-maxNumber of stars. Default 5.
data-valueThe starting rating. Default 0.
data-wordsOne word per star, comma-separated, shown under the stars. Default Awful,Poor,Okay,Good,Great!. none hides them.
data-promptShown before anyone rates. Default Tap a star to rate.
data-nameAdds a hidden input with this name, so the rating posts with a form.
data-colorStar colour. Default #f5b301.
data-autoplayIt 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.

1 · Snippet
<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>
2 · Your HTML
<div data-star-rating data-name="rating" aria-label="Rate this recipe"></div>