04平均値重ね塗り — 平均値の小数点部分が星1つ分の塗り幅の値として重なって現れる方式
平均値の整数部分の分だけ星が塗られた状態になり、残った小数点部分の値が次の星1つの塗り幅の値として重なって現れます。
使いどころ商品一覧の平均評価表示、レビュー要約カードの点数表示
average()counts = {5:12, 4:8, 3:3, 2:1, 1:1}queue = [5,3,5,4,2,5,4,3,5,1]

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__row" id="srRow">
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
<span class="sr__star"><svg class="sr__base" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg><span class="sr__fillclip" data-fc><svg class="sr__fill" viewBox="0 0 24 24"><path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z"/></svg></span></span>
</div>
<div class="sr__readout"><b id="srAvg">4.16</b> <span class="sr__count">(<span id="srN">25</span>件)</span></div>
<button class="sr__btn" type="button" id="srAdd">レビュー追加</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 별점별 응답 개수 합을 전체 개수로 나눈 가중 평균을 구해, 정수 부분만큼 별을 채우고
// 남은 소수점 부분을 다음 별 하나의 채움 너비 값으로 겹쳐 그린다 (average()).
@include stage($stage-yellow);
.sr {
display: grid;
gap: $sp-4;
place-items: center;
}
.sr__row { display: flex; gap: $sp-2; }
.sr__star {
position: relative;
width: 40px;
height: 40px;
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base { fill: $subject-ink; opacity: .18; }
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
transition: width $dur-base $ease-out;
}
.sr__fill { fill: $subject-blue-deep; }
.sr__readout {
font: 700 22px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
b { font-variant-numeric: tabular-nums; }
}
.sr__count {
font-size: 14px;
font-weight: 500;
opacity: .7;
}
.sr__btn {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
background: $subject-ink;
color: $stage-paper;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.94); }
}
@media (max-width: 340px) {
.sr__star { width: 30px; height: 30px; }
.sr__row { gap: $sp-1; }
.sr__readout { font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.sr__fillclip, .sr__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sr {
display: grid;
gap: 16px;
place-items: center;
}
.sr__row {
display: flex;
gap: 8px;
}
.sr__star {
position: relative;
width: 40px;
height: 40px;
}
.sr__base,
.sr__fill svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__base {
fill: #17141a;
opacity: 0.18;
}
.sr__fillclip {
position: absolute;
inset: 0;
width: 0%;
overflow: hidden;
transition: width 300ms ease-out;
}
.sr__fill {
fill: #0a4fe8;
}
.sr__readout {
font: 700 22px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.sr__readout b {
font-variant-numeric: tabular-nums;
}
.sr__count {
font-size: 14px;
font-weight: 500;
opacity: 0.7;
}
.sr__btn {
border: 0;
border-radius: 999px;
padding: 8px 20px;
background: #17141a;
color: #ffffff;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.sr__btn:active {
transform: scale(0.94);
}
@media (max-width: 340px) {
.sr__star {
width: 30px;
height: 30px;
}
.sr__row {
gap: 4px;
}
.sr__readout {
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr__fillclip, .sr__btn {
transition: none;
}
}(function () {
var root = document.getElementById('sr');
var clips = Array.prototype.slice.call(document.querySelectorAll('[data-fc]'));
var avgEl = document.getElementById('srAvg');
var nEl = document.getElementById('srN');
var btn = document.getElementById('srAdd');
// 계산 겹: 별점별 응답 개수(counts)로 가중 평균(합 / 개수)을 구하고, 정수 부분만큼 별을 채운 뒤
// 남은 소수점 부분을 다음 별 하나의 채움 너비 값으로 겹쳐 그린다.
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);
}
function addReview() {
var v = queue[qi % queue.length];
qi++;
counts[v] = (counts[v] || 0) + 1;
paint();
}
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); addReview(); });
paint();
// 자동 루프: 대기열의 다음 평점을 하나씩 추가해 평균 값이 실시간으로 옮겨가는 것을 반복해 보여준다.
var autoTimer = null;
function autoTick() { addReview(); if (qi % queue.length === 0) { counts = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 }; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 480); }
})();import { useEffect, useRef } from "react";
import styles from "./AverageFillOverlay.module.scss";
const STAR_PATH =
"M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279L12 19.771l-7.416 3.642 1.48-8.279-6.064-5.828 8.332-1.151z";
export default function AverageFillOverlay() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const clips = Array.prototype.slice.call(
root.querySelectorAll(`.${styles.fillclip}`)
) as HTMLElement[];
const avgEl = root.querySelector<HTMLElement>(`.${styles.avg}`);
const nEl = root.querySelector<HTMLElement>(`.${styles.n}`);
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
if (!avgEl || !nEl || !btn) return;
// 계산 겹: 별점별 응답 개수(counts)로 가중 평균(합 / 개수)을 구하고, 정수 부분만큼 별을 채운 뒤
// 남은 소수점 부분을 다음 별 하나의 채움 너비 값으로 겹쳐 그린다.
let counts: Record<number, number> = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 };
const queue = [5, 3, 5, 4, 2, 5, 4, 3, 5, 1];
let qi = 0;
function average() {
let sum = 0, n = 0;
for (const k in counts) { sum += Number(k) * counts[Number(k)]; n += counts[Number(k)]; }
return { avg: sum / n, n };
}
function paint() {
const r = average();
clips.forEach((el, idx) => {
const 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);
}
function addReview() {
const v = queue[qi % queue.length];
qi++;
counts[v] = (counts[v] || 0) + 1;
paint();
}
function onClick() { stopAutoplay(); addReview(); }
btn.addEventListener("click", onClick);
paint();
// 자동 루프: 대기열의 다음 평점을 하나씩 추가해 평균 값이 실시간으로 옮겨가는 것을 반복해 보여준다.
let autoTimer: number | null = null;
function autoTick() {
addReview();
if (qi % queue.length === 0) { counts = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 }; }
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 480); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.sr}>
<div className={styles.row}>
{[0, 1, 2, 3, 4].map((i) => (
<span key={i} className={styles.star}>
<svg className={styles.base} viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
<span className={styles.fillclip}>
<svg className={styles.fill} viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
</span>
</span>
))}
</div>
<div className={styles.readout}>
<b className={styles.avg}>4.16</b> <span className={styles.count}>(<span className={styles.n}>25</span>건)</span>
</div>
<button className={styles.btn} type="button">리뷰 추가</button>
</div>
</div>
);
}04 平均値重ね塗り — 平均値の小数点部分が星1つ分の塗り幅の値として重なって現れる方式 — 計測 累積面積 0.5% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · 星評価 UI 9選 — 塗りの値が状態に応じて変わる仕組み
このデモが属する記事
星評価 UI 9選 — 塗りの値が状態に応じて変わる仕組み9つのうち1つは指が動いた横方向の値に沿って塗りの値がすぐ変わり、1つは平均値の小数点部分が星1つの塗り幅の値として重なって現れ、もう1つは同じ星がもう一度押された状態になると塗りの値が0に向けて弾むように減ります。星評価 ui を9パター この記事のデモをすべて見る


