GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Star Rating UI: 9 Interactive Rating Patterns

One fill value follows the finger's position value in real time, one overlays an average's decimal part on a star, and one shrinks to zero with a

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine touches a real backend — every rating and review count you see is a local number defined in the script, and no fetch call exists anywhere. All nine answer a genuine user input rather than running on autoplay alone: two respond to hover (pointerenter/pointerleave), two to pointer movement (pointermove, one with full drag capture through Pointer Events), one to keydown arrows, and four to click — two plus two plus one plus four, nine real triggers out of nine.

Anatomy of nine patterns — which value changes and when

Nine patterns split by which value the input actually writes — a hovered order value, a horizontal coordinate value, a keyboard target value, or a committed rating value — and that split is what keeps a star rating ui design collection from reading as nine identical rows of five stars.

Why two patterns write a preview value instead of the real one

01 and 06 both listen to pointerenter, but neither commits anything on entry — 01's previewTo() only paints a temporary fill up to the hovered star's order value, and revertToValue() snaps it back to the committed value on pointerleave. 06's requestSwap() goes further and holds even the label swap behind a 40ms setTimeout, so a cursor sweeping across the row never triggers the tooltip at all; only a hover that outlasts the delay gets a commit(). The click or the leave, not the hover, is what settles the final state in both.

Why two patterns turn a position value into the rating itself

02 and 09 both read a raw horizontal coordinate, but at opposite resolutions. 02's valueFromX() walks each star's getBoundingClientRect() and snaps the result to a whole or half star depending on which half of the width the pointer falls in, then paint() converts that value into per-star fill widths. 09's setPos() instead takes any pixel offset on the track, converts it into a continuous 0–5 value with one decimal, and keeps repainting on every pointermove while a finger is down — no snapping until the finger lifts.

Why five patterns commit a discrete value on a single event

03, 04, 05, 07, and 08 each change state exactly once per interaction. 03 moves a target value by one per arrow keypress and lets tick() ease the display toward it. 04's addReview() pushes one queued rating into counts and recomputes the weighted average. 05 converts one clicked row's count into a ratio against the total. 07 passes one clicked order value through mix() to derive a color. 08 compares the clicked star's order value against the current value and resets to zero on a match. One event in, one painted state out.

Code per pattern

01Hover preview fill — the filled value previews up to the hovered star's order value, then reverts once the pointer leaves

While the pointer stays over a star, the filled value previews up to that star's order value, and once the pointer leaves, the filled value reverts to the actually selected value. A product review form or a recipe card reads this way — the preview shows what a click would commit before it is clicked.

Hover preview fill — the filled value previews up to the hovered star's order value, then reverts once the pointer leaves
Open in a new tab

01 · Hover preview fill — the filled value previews up to the hovered star's order value, then reverts once the pointer leaves — Measured area 3.944% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

ratioOf(n)previewTo(i) / revertToValue()value = 3

What the numbers say

Across 23 tracked frames, 01 moves on 21 of them, covering a 3.94% cumulative area at a 75.6 average intensity — near-maximum frame coverage with minimal area, because the autoplay loop sweeps the preview across the whole row so something is always in motion, yet a five-star row simply doesn't occupy much of the 480×300 stage.

  // Calculation layer: the fill count n divided by the total star count gives the
  // percentage shown on screen — the fill comes from this arithmetic, not a lookup.
  function ratioOf(n) { return Math.round((n / TOTAL) * 100); }

  function paint(n, isPreview) {
    stars.forEach(function (s, idx) {
      s.classList.toggle('sr__star--filled', idx < n);
    });
    numEl.textContent = String(n);
    pctEl.textContent = String(ratioOf(n));
    readout.classList.toggle('sr__readout--preview', !!isPreview);
    readout.firstChild.textContent = isPreview ? 'Preview ' : 'Selected ';
  }

02Half-step precision fill — the filled value splits into a whole or half value depending on which half of a star's width the pointer falls in

The pointer's horizontal position value determines which half of a single star's width value it falls in, and the filled value is set to either a whole number value or a half-step value accordingly. A hotel detail page or a book review widget uses this precision when a 3.5 matters.

Half-step precision fill — the filled value splits into a whole or half value depending on which half of a star's width the pointer falls in
Open in a new tab

02 · Half-step precision fill — the filled value splits into a whole or half value depending on which half of a star's width the pointer falls in — Measured area 4.027% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

valueFromX(clientX)STAR_W = 44steps = [0.5, 1, 1.5, …, 5]

What the numbers say

Across 23 tracked frames, 02 moves on all 23, covering a 4.03% cumulative area at a 153.2 average intensity — full frame coverage and the second-highest intensity of the nine, because the fill edge can land anywhere inside a star's width rather than only at its boundary, so every pixel of pointer travel repaints the clip width.

  // Calculation layer: using each star's actual rendered width (getBoundingClientRect),
  // decide whether the pointer sits in the left half or the right half, and snap the
  // value to whole or half steps only.
  function valueFromX(clientX) {
    for (var i = 0; i < stars.length; i++) {
      var r = stars[i].getBoundingClientRect();
      if (clientX < r.left) return i;
      if (clientX <= r.right) {
        var half = r.left + r.width / 2;
        return clientX < half ? i + 0.5 : i + 1;
      }
    }
    return stars.length;
  }

  function paint(v) {
    stars.forEach(function (s, idx) {
      var starVal = Math.max(0, Math.min(1, v - idx));
      clips[idx].style.width = (starVal * 100).toFixed(0) + '%';
    });
    numEl.textContent = v.toFixed(1);
  }

03Keyboard arrow select — the selected star's order value increases or decreases by one for each pressed direction value

Each right or left arrow keypress increases or decreases the selected star's order value by one, and once that value passes either end of the range it stops at the end value. A checkout flow that must stay mouse-free runs on this pattern alone.

Keyboard arrow select — the selected star's order value increases or decreases by one for each pressed direction value
Open in a new tab

03 · Keyboard arrow select — the selected star's order value increases or decreases by one for each pressed direction value — Measured area 3.694% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

tick() / animateTo(t)display += (target - display) * 0.2seq = [4, 5, 3, 1, 0, 2, 3]

What the numbers say

Across 23 tracked frames, 03 moves on 16 of them, covering a 3.69% cumulative area at a 127.6 average intensity — the ease shows up as a run of consecutive moving frames after each keypress rather than a single jump, since tick() keeps the display value chasing the target across several frames before settling.

  // Calculation layer: linear interpolation (lerp) that moves the display value only
  // 20% of the way toward the target each frame — the value never jumps, it curves.
  function tick() {
    display += (target - display) * 0.2;
    if (Math.abs(target - display) < 0.01) {
      display = target;
      paint(display);
      raf = null;
      return;
    }
    paint(display);
    raf = requestAnimationFrame(tick);
  }
  function animateTo(t) {
    target = Math.max(0, Math.min(TOTAL, t));
    row.setAttribute('aria-valuenow', String(target));
    numEl.textContent = String(target);
    if (raf === null) raf = requestAnimationFrame(tick);
  }

04Average fill overlay — the average value's decimal portion appears as a partial fill-width value overlaid on one star

Stars fill up to the average value's whole-number portion, and the remaining decimal portion value appears as a partial fill-width value overlaid on the next star. This is the read-only end state a review summary reaches after enough people have rated — a store listing or an app detail page shows exactly this shape.

Average fill overlay — the average value's decimal portion appears as a partial fill-width value overlaid on one star
Open in a new tab

04 · Average fill overlay — the average value's decimal portion appears as a partial fill-width value overlaid on one star — Measured area 0.5% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

average()counts = {5:12, 4:8, 3:3, 2:1, 1:1}queue = [5,3,5,4,2,5,4,3,5,1]

What the numbers say

Across 23 tracked frames, 04 moves on 18 of them, covering a 0.50% cumulative area at a 73.1 average intensity — the smallest area of the nine by far, because each added review nudges the average by a shrinking fraction, so a bar that already sits at 4.1x repaints a sliver of one star's clip width and almost nothing else.

  // Calculation layer: compute the weighted average (sum / count) from the per-score
  // response counts, fill stars for the whole-number part, then overlay the leftover
  // decimal part as the fill width of the next single star.
  var counts = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 };
  var queue = [5, 3, 5, 4, 2, 5, 4, 3, 5, 1];
  var qi = 0;

  function average() {
    var sum = 0, n = 0;
    for (var k in counts) { sum += Number(k) * counts[k]; n += counts[k]; }
    return { avg: sum / n, n: n };
  }

  function paint() {
    var r = average();
    clips.forEach(function (el, idx) {
      var starVal = Math.max(0, Math.min(1, r.avg - idx));
      el.style.width = (starVal * 100).toFixed(1) + '%';
    });
    avgEl.textContent = r.avg.toFixed(2);
    nEl.textContent = String(r.n);
  }

05Count breakdown bars — each score's response count value splits into a horizontal bar length value

Each score from five stars down to one has its response count value converted into a ratio value against the total response count value, becoming that row's bar length value. A ratings distribution block on a product page sits beside the average the way this row list sits beside the star row.

Count breakdown bars — each score's response count value splits into a horizontal bar length value
Open in a new tab

05 · Count breakdown bars — each score's response count value splits into a horizontal bar length value — Measured area 22.699% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

total() / paint()counts = {5:42, 4:21, 3:9, 2:4, 1:3}bump = [5,3,4,5,2,5,1,4,5,3]

What the numbers say

Across 23 tracked frames, 05 moves on 15 of them, covering a 22.70% cumulative area at a 34.0 average intensity — the largest area of the nine by a wide margin, because five separate bars spread across the full stage width all grow on every repaint, and the lowest intensity with it: the same event repaints many pixels gently rather than a few pixels sharply.

  var counts = { 5: 42, 4: 21, 3: 9, 2: 4, 1: 3 };
  var selected = null;

  // Calculation layer: each score's response count divided by the total response
  // count becomes that row's bar length value, in percent.
  function total() {
    var t = 0;
    for (var k in counts) t += counts[k];
    return t;
  }

  function paint() {
    var t = total();
    rows.forEach(function (row) {
      var star = Number(row.getAttribute('data-star'));
      var pct = t ? (counts[star] / t) * 100 : 0;
      row.querySelector('[data-bar]').style.width = pct.toFixed(1) + '%';
      row.querySelector('[data-pct]').textContent = Math.round(pct) + '% (' + counts[star] + ')';
      row.classList.toggle('sr__row--on', star === selected);
      row.setAttribute('aria-pressed', String(star === selected));
    });
  }

06Tooltip label swap — the label text value beside the star changes to match the hovered star's order value

Each time the hovered star's order value changes, the label text value mapped to that value changes together and appears beside the star, and it disappears once the pointer leaves. A rating prompt that says "Average" or "Excellent" instead of a bare 3 or 5 keeps the meaning attached to the number.

Tooltip label swap — the label text value beside the star changes to match the hovered star's order value
Open in a new tab

06 · Tooltip label swap — the label text value beside the star changes to match the hovered star's order value — Measured area 6.522% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

LABELS = {1:'매우 별로', …, 5:'최고'}positionTip(i) — getBoundingClientRect 중심 xrequestSwap(i) — 40ms debounce

What the numbers say

Across 23 tracked frames, 06 moves on 9 of them, covering a 6.52% cumulative area at a 143.6 average intensity — few moving frames but sharp ones, because the 40ms debounce skips every star the cursor merely crosses, so only the stars where the tooltip actually commits repaint, and the tooltip pop itself is a high-contrast change.

  // Calculation layer: move the star's actual rendered center x
  // (getBoundingClientRect) into the tooltip's translateX value, and pass index
  // changes through a short delay (debounce) before swapping the label text —
  // a timing calculation that stops the text flickering star by star when the
  // cursor sweeps across quickly.
  function positionTip(i) {
    var star = stars[i - 1];
    var sr = star.getBoundingClientRect();
    var rr = row.getBoundingClientRect();
    var cx = sr.left + sr.width / 2 - rr.left;
    tip.style.transform = 'translateX(' + cx.toFixed(1) + 'px) translateX(-50%)';
  }

  function commit(i) {
    shownIndex = i;
    tip.textContent = LABELS[i];
    positionTip(i);
    tip.classList.add('sr__tip--show');
    stars.forEach(function (s, idx) { s.classList.toggle('sr__star--filled', idx < i); });
  }

  function requestSwap(i) {
    if (pendingTimer) clearTimeout(pendingTimer);
    pendingTimer = setTimeout(function () { commit(i); }, 40);
  }

07Value color shift — the star's color value changes in stages according to the size of the selected value

When the selected star's order value sits in a lower range the color value leans one way, in a higher range it leans the other way, and values in between blend continuously between the two colors. A satisfaction survey or a service rating can carry tone in the color itself, not just the count.

Value color shift — the star's color value changes in stages according to the size of the selected value
Open in a new tab

07 · Value color shift — the star's color value changes in stages according to the size of the selected value — Measured area 3.591% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

mix(t) — RGB 채널 선형보간LOW = [0xc2, 0x36, 0x1a]HIGH = [0x4c, 0xd4, 0xa6]

What the numbers say

Across 23 tracked frames, 07 moves on all 23, covering a 3.59% cumulative area at a 25.7 average intensity — the lowest intensity of the nine, because a color shift between two fixed endpoints changes each pixel only slightly per step, where a position or width change redraws a whole edge at full contrast.

  // Calculation layer: turn the selected value (1–5) into a 0–1 ratio t, then
  // linearly interpolate (mix) each R·G·B channel of the two token colors by t —
  // channel-wise arithmetic blending, not an HSL rotation.
  var LOW = [0xc2, 0x36, 0x1a];
  var HIGH = [0x4c, 0xd4, 0xa6];

  function mix(t) {
    var c = LOW.map(function (v, i) { return Math.round(v + (HIGH[i] - v) * t); });
    return 'rgb(' + c.join(',') + ')';
  }

  function paint(value) {
    var t = (value - 1) / 4;
    var color = mix(t);
    stars.forEach(function (s, idx) {
      s.style.color = idx < value ? color : '';
      s.classList.toggle('sr__star--filled', idx < value);
    });
    numEl.textContent = String(value);
    readout.style.color = color;
  }

08Reset clear spring — pressing the already-selected star again shrinks the filled value to zero with a bounced motion

When the star matching the already-selected value is pressed again, the filled value shrinks toward zero through a bounced motion value, and the selected value clears along with it. A "clear my rating" affordance that needs no separate button lives inside the same row of stars.

Reset clear spring — pressing the already-selected star again shrinks the filled value to zero with a bounced motion
Open in a new tab

08 · Reset clear spring — pressing the already-selected star again shrinks the filled value to zero with a bounced motion — Measured area 4.791% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

reset() — wasFilled 필터srBounce — scale(1)→scale(1.3)→scale(.85)value = 4 (초기값)

What the numbers say

Across 23 tracked frames, 08 moves on 9 of them, covering a 4.79% cumulative area at a 108.7 average intensity — the motion is bursty rather than continuous, since the bounce keyframes only fire on the reset instant and hold steady the rest of the cycle, but the scale-and-fade of a full row of filled stars is a strong change when it does land.

  // Calculation layer: on reset, send the fill value to zero while adding a pop
  // animation class with spring easing (an overshoot before exit) only to the
  // stars that were already filled.
  function reset() {
    var wasFilled = stars.filter(function (s, idx) { return idx < value; });
    value = 0;
    paint(value);
    wasFilled.forEach(function (s) { s.classList.add('sr__star--bounce'); });
    setTimeout(function () {
      wasFilled.forEach(function (s) { s.classList.remove('sr__star--bounce'); });
    }, 620);
  }

09Touch drag scrub — the filled value updates in real time following the finger's horizontal position value

While a finger stays pressed and moves across the star row, the filled value follows that changing horizontal position value on the same screen, and once the finger lifts, the value locks in as the selected value. A mobile rating step in a one-thumb flow prefers this over five separate tap targets.

Touch drag scrub — the filled value updates in real time following the finger's horizontal position value
Open in a new tab

09 · Touch drag scrub — the filled value updates in real time following the finger's horizontal position value — Measured area 4.96% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

runInertia() — velocity *= 0.945Math.abs(velocity) > 0.015onDown/onMove — pointerId capture

What the numbers say

Across 23 tracked frames, 09 moves on 17 of them, covering a 4.96% cumulative area at a 173.2 average intensity — the highest intensity of the nine, because inertia keeps the value drifting after the finger lifts, and each drift step repaints the fill edge and the track thumb together at full contrast.

  function onUp() {
    if (!dragging) return;
    dragging = false;
    runInertia();
  }
  // Calculation layer: inherit the velocity (px/ms) at the moment the finger lifts
  // and keep decaying it (friction) every frame while the position keeps moving —
  // an inertia scrub so the value flows to a stop instead of freezing on release.
  function runInertia() {
    velocity *= 0.945;
    setPos(pos + velocity * 16);
    if (Math.abs(velocity) > 0.015 && pos > 0 && pos < width()) {
      inertiaRaf = requestAnimationFrame(runInertia);
    } else {
      inertiaRaf = null;
    }
  }

Where values reshape a pattern — position, ratio, and time boundaries

Why 03's lerp coefficient decides how the value travels, not where it lands

The 0.2 in display += (target - display) * 0.2 never changes what the target is — animateTo() still clamps to the same 0–5 range and writes the same aria-valuenow either way. At 0.2 the displayed value eases toward the target over several frames, so a direction reversal reads as a smooth curve; at 0.4 the same arrow keypress snaps the value most of the way in one frame, which feels immediate but shortens the visible inertia to a flicker. The landing point never moves.

Why 06's 40ms debounce decides which hovers exist at all, not how the tooltip looks

The delay sits entirely inside requestSwap() — a hover that lasts less than 40ms is cancelled by the next clearTimeout before commit() ever runs, so its tooltip simply never exists. At 90ms even more brushes vanish, at the cost of a deliberate hover waiting a beat longer for its label. The tooltip's position math (positionTip()) and its text are untouched by the number.

Why 09's 0.945 friction decides how far the value drifts, not where it stops

velocity *= 0.945 multiplies what remains of the release velocity each frame — it never edits the position the finger actually reached, only how much further the value coasts past it. At 0.975 the same release speed glides for many more frames, so the locked-in value can land well beyond where the finger lifted; at 0.945 the drift dies within a short run and the value settles close to the release point.

Where it breaks — rapid clicks, narrow screens, mixed touch and mouse input

What happens on rapid clicks while the bounce is still running

08's reset() schedules a setTimeout that removes sr__star--bounce after 620ms, but nothing cancels it when a new click re-fills the stars first — the pending timer still fires mid-fill and strips the class from stars that are supposed to be filling, cutting the bounce short. 09 guards its own animation better: onDown() cancels any pending inertiaRaf before a new drag starts, so rapid re-presses never stack two inertia loops on the same value.

What happens when the row sits near a narrow screen's edge

Every one of the nine ships an @media (max-width: 340px) block that shrinks the stars, and the value math adapts because 02, 06, and 09 all measure with getBoundingClientRect() at event time rather than trusting a cached width — except 06's tooltip, whose translateX position is never clamped against the row's bounds, so on a narrow screen the label can extend past the row's edge rather than staying centered over the end stars.

What happens when touch and mouse input mix on the same element

01 and 06 are hover-driven (pointerenter/pointerleave), and a finger that taps a star on a touch screen can fire a stray enter-leave pair, leaving a preview or tooltip state behind that only a second tap clears. 02 listens to pointermove on the row without setPointerCapture, so a touch drag that slips off the row stops updating mid-gesture. 09 handles this deliberately — track.setPointerCapture(e.pointerId) in onDown() keeps every move routed to the track until the finger lifts, which is exactly the unification Pointer Events exists for.

Where this set differs from 9 CSS Heart Animations — One Line, Copy-Paste, 9 Segmented Control UI Patterns — Copy-Paste, and 9 time picker ui patterns, from a point to a span is which value the interaction writes. The heart set animates a like button's own state change, so the only value is on-or-off and the craft is in the easing. The segmented control set moves a single selection across a fixed row of options — closest in shape to this set, but the position value belongs to the segment, never a fractional fill. The time picker set turns a dragged or typed point into a span value, so the value has two ends to resolve where a rating has one. All nine source files behind this star rating set, unlocked with the password haq93vzh, drop straight into a real review form once the demo's autoplay loop is removed.

Three variants and accessibility

Only three of these nine star rating ui component patterns expose a constant worth turning into a shipped variant — not because the other six lack tunable numbers, but because the lerp coefficient, the debounce delay, and the friction multiplier are the three where a single value change most visibly reshapes how forgiving, deliberate, or fluid the interaction feels.

Variant What changes What it trades off
03's lerp coefficient from 0.2 to 0.4 How fast the displayed value closes in on the arrow-key target At 0.2 a reversal draws a visible eased curve; at 0.4 the value arrives almost instantly, reading as snappier but spending the inertia that made the motion feel physical
06's swap delay from 40ms to 90ms Which hovers are long enough to swap the tooltip label at all At 40ms a quick brush across a star still shows its label; at 90ms accidental brushes stay silent, but a deliberate hover waits a beat longer before its label appears
09's inertia friction from 0.945 to 0.975 How far the value coasts after the finger lifts At 0.945 the drift dies shortly after release and the value settles near the lift point; at 0.975 the same release glides far past it, feeling fluid but locking in a value the finger never touched

Accessibility here is uneven by design of the demo, and the source says exactly where. 01 is the most complete: role="radiogroup" with aria-label on the row, role="radio" and aria-checked per star, kept in sync inside commit(). 03 is second: role="slider" with tabindex="0", aria-valuemin/aria-valuemax, and an aria-valuenow written on every animateTo(). 05 carries role="list" with role="listitem" and aria-pressed per row. The remaining six set no ARIA roles or states at all — 02, 04, 06, 07, 08, and 09 paint visual state only. Each item's @media (prefers-reduced-motion: reduce) query turns off its CSS transitions (08 additionally zeroes the bounce animation), while each script's own reduce check gates only the autoplay setInterval — real input keeps working identically either way.

FAQ

Does a css star rating percentage display need JavaScript at all?

The percentage readout in 01 comes from ratioOf() — Math.round((n / TOTAL) * 100) — and stays in sync with the fill because both come from the same count value. A pure css star rating bar can hard-code a fixed width, but the moment the value must change from input, something has to recompute it, and here that is four lines of script.

Can the half-star pattern and the drag pattern share one value scale?

They already do — 02 snaps to 0.5 steps through valueFromX() while 09 keeps one decimal through paintFromPos(), and both clamp inside 0–5 with the same Math.max/Math.min shape. The difference is resolution, not range: swapping 09's continuous scale into 02's row only means rounding each drag position to the nearer half.

Do any of these nine send the rating anywhere?

No — every rating, count, and average is a number declared in the script, the "Add review" button in 04 only pushes the next value from a local queue array into counts, and no fetch, XHR, or form submission exists in any of the nine. A star rating ui component built this way is meant to be wired to a real endpoint after pasting it in, not before.

Sources: MDN — Element.getBoundingClientRect() · MDN — Pointer events · MDN — requestAnimationFrame() · MDN — prefers-reduced-motion · MDN — ARIA slider role

Enter the archive password

The password is inside this article. You will find it as you read.