04별점 분포 요약 바
5점부터 1점까지 다섯 줄의 막대가 왼쪽에서 차오르고, 옆의 평균 점수와 후기 개수가 마지막 값까지 한 칸씩 올라갑니다.
쓰임쇼핑몰 상품 상세의 별점 요약 · 앱 스토어형 평점 블록
scaleXgrid-areaaria-label

<main class="stage">
<section class="rd is-demo" aria-label="별점 분포 요약">
<svg class="rd__defs" width="0" height="0" aria-hidden="true" focusable="false">
<symbol id="rd-star" viewBox="0 0 256 256" fill="currentColor"><path d="M229.06,108.79l-48.7,42,14.88,62.79a8.4,8.4,0,0,1-12.52,9.17L128,189.09,73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6a8.36,8.36,0,0,1,15.48,0l24.62,59.6L224.27,94A8.46,8.46,0,0,1,229.06,108.79Z" opacity="0.2"/><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></symbol>
<symbol id="rd-star-half" viewBox="0 0 256 256" fill="currentColor"><path d="M128,24V189.09L73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6A8.26,8.26,0,0,1,128,24Z" opacity="0.2"/><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.4,16.4,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26ZM69.09,215.94c-.15.09-.2.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42a.39.39,0,0,1-.13-.51c.09-.27.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l17-41.08V184.63ZM223.84,102.73l-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0L136,184.63V50.78l17,41.08a8,8,0,0,0,6.75,4.92l63.91,5.16c.16,0,.25,0,.34.29S224,102.63,223.84,102.73Z"/></symbol>
</svg>
<div class="rd__card">
<div class="rd__summary">
<p class="rd__avg" role="img" aria-label="5점 만점에 4.7점">
<span class="rd__num">
<span class="rd__n rd__n--1">4.1</span>
<span class="rd__n rd__n--2">4.3</span>
<span class="rd__n rd__n--3">4.5</span>
<span class="rd__n rd__n--4">4.7</span>
</span>
<span class="rd__stars">
<svg width="14" height="14" aria-hidden="true" focusable="false"><use href="#rd-star"/></svg>
<svg width="14" height="14" aria-hidden="true" focusable="false"><use href="#rd-star"/></svg>
<svg width="14" height="14" aria-hidden="true" focusable="false"><use href="#rd-star"/></svg>
<svg width="14" height="14" aria-hidden="true" focusable="false"><use href="#rd-star"/></svg>
<svg width="14" height="14" aria-hidden="true" focusable="false"><use href="#rd-star-half"/></svg>
</span>
</p>
<p class="rd__count" role="img" aria-label="후기 1,509개">
<span class="rd__c rd__c--1">후기 812개</span>
<span class="rd__c rd__c--2">후기 1,140개</span>
<span class="rd__c rd__c--3">후기 1,382개</span>
<span class="rd__c rd__c--4">후기 1,509개</span>
</p>
</div>
<ul class="rd__rows">
<li class="rd__item">
<button class="rd__row rd__row--5" type="button" aria-pressed="false" aria-label="5점 후기가 68퍼센트">
<span class="rd__lv" aria-hidden="true">5<svg width="10" height="10" aria-hidden="true" focusable="false"><use href="#rd-star"/></svg></span>
<span class="rd__track" aria-hidden="true"><span class="rd__bg"></span><span class="rd__fill"></span></span>
<span class="rd__pct" aria-hidden="true">68%</span>
</button>
</li>
<li class="rd__item">
<button class="rd__row rd__row--4" type="button" aria-pressed="false" aria-label="4점 후기가 21퍼센트">
<span class="rd__lv" aria-hidden="true">4<svg width="10" height="10" aria-hidden="true" focusable="false"><use href="#rd-star"/></svg></span>
<span class="rd__track" aria-hidden="true"><span class="rd__bg"></span><span class="rd__fill"></span></span>
<span class="rd__pct" aria-hidden="true">21%</span>
</button>
</li>
<li class="rd__item">
<button class="rd__row rd__row--3" type="button" aria-pressed="false" aria-label="3점 후기가 7퍼센트">
<span class="rd__lv" aria-hidden="true">3<svg width="10" height="10" aria-hidden="true" focusable="false"><use href="#rd-star"/></svg></span>
<span class="rd__track" aria-hidden="true"><span class="rd__bg"></span><span class="rd__fill"></span></span>
<span class="rd__pct" aria-hidden="true">7%</span>
</button>
</li>
<li class="rd__item">
<button class="rd__row rd__row--2" type="button" aria-pressed="false" aria-label="2점 후기가 3퍼센트">
<span class="rd__lv" aria-hidden="true">2<svg width="10" height="10" aria-hidden="true" focusable="false"><use href="#rd-star"/></svg></span>
<span class="rd__track" aria-hidden="true"><span class="rd__bg"></span><span class="rd__fill"></span></span>
<span class="rd__pct" aria-hidden="true">3%</span>
</button>
</li>
<li class="rd__item">
<button class="rd__row rd__row--1" type="button" aria-pressed="false" aria-label="1점 후기가 1퍼센트">
<span class="rd__lv" aria-hidden="true">1<svg width="10" height="10" aria-hidden="true" focusable="false"><use href="#rd-star"/></svg></span>
<span class="rd__track" aria-hidden="true"><span class="rd__bg"></span><span class="rd__fill"></span></span>
<span class="rd__pct" aria-hidden="true">1%</span>
</button>
</li>
</ul>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 276 공통 팔레트 — 아홉 항목이 같은 값을 쓴다. 괄호 안은 실측 대비비
$pf-ink: #1c1b22; // 크림 위 글자 15.99:1
$pf-cream: #fbf7ef; // 카드 바탕. 오렌지 무대 위 흰 글자(3.32:1)를 피하려고 카드를 먼저 깐다
$pf-stone: #565062; // 크림 위 보조 글자 7.23:1
$pf-flame: $stage-orange; // 무대 · 별 · 고른 줄의 채움 (크림 위 3.1:1 — 글자가 아니라 도형에만)
$duration: $dur-loop; // 다섯 막대가 차례로 차오르는 한 바퀴
$easing: $ease-spring; // 막대가 차오를 때의 감속
$color: $pf-ink; // 막대 채움 색
@include stage($pf-flame);
// 시각 요소 reset — 기본 margin 을 명시로 0
p, ul { margin: 0; }
.rd {
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
}
.rd__defs { display: none; }
.rd__card {
box-sizing: border-box;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: $sp-4;
padding: $sp-4;
border-radius: $r-card;
background: $pf-cream;
}
// ── 왼쪽: 평균 점수 · 별 · 후기 개수 ─────────────────────
.rd__summary {
display: grid;
justify-items: start;
gap: $sp-1;
}
.rd__avg {
display: grid;
justify-items: start;
gap: $sp-1;
}
// 숫자 여러 벌을 같은 칸에 포갠다 — 크로스페이드가 아니라 steps(1, end) 로 갈아 끼운다
.rd__num { display: grid; justify-items: start; }
.rd__n {
grid-area: 1 / 1;
color: $pf-ink;
font-weight: 800; font-size: 34px; line-height: 1;
letter-spacing: -.02em;
font-variant-numeric: tabular-nums;
opacity: 0;
}
// 정적 경로 — 루프가 없으면 마지막 값만 보인다 (.is-demo 연출보다 먼저 쓴다)
.rd__n--4 { opacity: 1; }
.rd__stars { display: flex; align-items: center; gap: $sp-1; color: $pf-flame; }
.rd__stars svg { display: block; }
.rd__count {
display: grid;
justify-items: start;
color: $pf-stone;
font-weight: 600; font-size: 11px; line-height: 1.2;
}
.rd__c { grid-area: 1 / 1; opacity: 0; white-space: nowrap; }
.rd__c--4 { opacity: 1; }
// ── 오른쪽: 5점 → 1점 다섯 줄 ───────────────────────────
.rd__rows { display: grid; gap: $sp-2; padding: 0; list-style: none; }
// 「별점 라벨 · 트랙 · 퍼센트」 세 칸. 트랙과 채움은 같은 칸에 겹치므로
// flex: 1 1 0 + scaleX 조합(BD19 오탐)을 쓰지 않는다
.rd__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: $sp-2;
width: 100%;
padding: 0;
border: 0;
border-radius: $r-xs;
background: none;
color: $pf-stone;
font-family: inherit;
font-weight: 700; font-size: 11px; line-height: 1;
text-align: left;
cursor: pointer;
}
.rd__row--5 { --p: .68; }
.rd__row--4 { --p: .21; }
.rd__row--3 { --p: .07; }
.rd__row--2 { --p: .03; }
.rd__row--1 { --p: .01; }
.rd__lv {
display: flex;
align-items: center;
gap: $sp-1;
color: $pf-ink;
font-variant-numeric: tabular-nums;
}
.rd__lv svg { display: block; color: $pf-flame; }
.rd__track { display: grid; }
.rd__bg, .rd__fill {
grid-area: 1 / 1;
height: 8px;
border-radius: $r-pill;
}
.rd__bg { background: rgba(28, 27, 34, .12); }
.rd__fill {
background: $color;
transform: scaleX(var(--p));
transform-origin: left center; // 규칙8: 분포 막대 왼쪽 채움
}
.rd__pct {
min-width: 26px;
color: $pf-stone;
font-variant-numeric: tabular-nums;
text-align: right;
}
.rd__row:hover .rd__fill,
.rd__row[aria-pressed="true"] .rd__fill { background: $pf-flame; }
.rd__row[aria-pressed="true"] { color: $pf-ink; }
.rd.is-picked .rd__row[aria-pressed="false"] { opacity: .42; }
.rd__row:focus-visible {
outline: 2px solid $pf-ink;
outline-offset: 2px;
}
// ── 자동 재생 ────────────────────────────────────────────
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로 쓴다.
// 지연이 걸린 루프에는 fill-mode: backwards — 없으면 0프레임에 완성 상태가 한 번 번쩍인다
.rd.is-demo .rd__fill {
animation-name: rdFill;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rd.is-demo .rd__row--4 .rd__fill { animation-delay: .16s; }
.rd.is-demo .rd__row--3 .rd__fill { animation-delay: .32s; }
.rd.is-demo .rd__row--2 .rd__fill { animation-delay: .48s; }
.rd.is-demo .rd__row--1 .rd__fill { animation-delay: .64s; }
// 카운트업은 steps(1, end) 로 한 칸씩 컷 — opacity 크로스페이드는 poster 에 반투명 유령을 남긴다
.rd.is-demo .rd__n,
.rd.is-demo .rd__c {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.rd.is-demo .rd__n--1, .rd.is-demo .rd__c--1 { animation-name: rdStep1; }
.rd.is-demo .rd__n--2, .rd.is-demo .rd__c--2 { animation-name: rdStep2; }
.rd.is-demo .rd__n--3, .rd.is-demo .rd__c--3 { animation-name: rdStep3; }
.rd.is-demo .rd__n--4, .rd.is-demo .rd__c--4 { animation-name: rdStep4; }
// 차오른 막대는 끝까지 남긴다 — 한 바퀴 안에서 비웠다 채우면 poster 가 빈 막대를 고른다.
// 되감기는 루프 경계(24프레임의 마지막 장은 95.8% 지점)에서 한 번에 일어난다
@keyframes rdFill {
0% { transform: scaleX(0); }
46%, 100% { transform: scaleX(var(--p)); }
}
@keyframes rdStep1 { 0% { opacity: 1; } 22%, 100% { opacity: 0; } }
@keyframes rdStep2 { 0% { opacity: 0; } 22% { opacity: 1; } 50%, 100% { opacity: 0; } }
@keyframes rdStep3 { 0% { opacity: 0; } 50% { opacity: 1; } 84%, 100% { opacity: 0; } }
// 마지막 값은 막대가 다 찬 84% 에 도착해 끝까지 유지한다 (90% 안)
@keyframes rdStep4 { 0% { opacity: 0; } 84%, 100% { opacity: 1; } }
// 좁은 화면 — 두 칸을 유지하고 글자·간격만 줄인다 (여기서는 값을 올리지 않는다)
@media (max-width: 340px), (max-height: 220px) {
.rd { width: 100%; }
.rd__card { gap: $sp-3; padding: $sp-3; }
.rd__n { font-size: 26px; }
.rd__stars { gap: 0; }
.rd__stars svg { width: 11px; height: 11px; }
.rd__count { font-size: 10px; }
.rd__rows { gap: $sp-1; }
.rd__row { gap: $sp-1; font-size: 10px; }
.rd__bg, .rd__fill { height: 6px; }
.rd__pct { min-width: 22px; }
}
@media (prefers-reduced-motion: reduce) {
// .rd.is-demo .rd__fill (0,3,0) 를 이기려면 !important 가 필요하다
.rd__fill, .rd__n, .rd__c { animation: none !important; }
.rd__n, .rd__c { opacity: 0 !important; }
.rd__n--4, .rd__c--4 { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
p, ul {
margin: 0;
}
.rd {
box-sizing: border-box;
width: clamp(288px, 94vw, 432px);
}
.rd__defs {
display: none;
}
.rd__card {
box-sizing: border-box;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 16px;
padding: 16px;
border-radius: 16px;
background: #fbf7ef;
}
.rd__summary {
display: grid;
justify-items: start;
gap: 4px;
}
.rd__avg {
display: grid;
justify-items: start;
gap: 4px;
}
.rd__num {
display: grid;
justify-items: start;
}
.rd__n {
grid-area: 1/1;
color: #1c1b22;
font-weight: 800;
font-size: 34px;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
opacity: 0;
}
.rd__n--4 {
opacity: 1;
}
.rd__stars {
display: flex;
align-items: center;
gap: 4px;
color: #ff4d1f;
}
.rd__stars svg {
display: block;
}
.rd__count {
display: grid;
justify-items: start;
color: #565062;
font-weight: 600;
font-size: 11px;
line-height: 1.2;
}
.rd__c {
grid-area: 1/1;
opacity: 0;
white-space: nowrap;
}
.rd__c--4 {
opacity: 1;
}
.rd__rows {
display: grid;
gap: 8px;
padding: 0;
list-style: none;
}
.rd__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
width: 100%;
padding: 0;
border: 0;
border-radius: 4px;
background: none;
color: #565062;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1;
text-align: left;
cursor: pointer;
}
.rd__row--5 {
--p: .68;
}
.rd__row--4 {
--p: .21;
}
.rd__row--3 {
--p: .07;
}
.rd__row--2 {
--p: .03;
}
.rd__row--1 {
--p: .01;
}
.rd__lv {
display: flex;
align-items: center;
gap: 4px;
color: #1c1b22;
font-variant-numeric: tabular-nums;
}
.rd__lv svg {
display: block;
color: #ff4d1f;
}
.rd__track {
display: grid;
}
.rd__bg, .rd__fill {
grid-area: 1/1;
height: 8px;
border-radius: 999px;
}
.rd__bg {
background: rgba(28, 27, 34, 0.12);
}
.rd__fill {
background: #1c1b22;
transform: scaleX(var(--p));
transform-origin: left center;
}
.rd__pct {
min-width: 26px;
color: #565062;
font-variant-numeric: tabular-nums;
text-align: right;
}
.rd__row:hover .rd__fill,
.rd__row[aria-pressed=true] .rd__fill {
background: #ff4d1f;
}
.rd__row[aria-pressed=true] {
color: #1c1b22;
}
.rd.is-picked .rd__row[aria-pressed=false] {
opacity: 0.42;
}
.rd__row:focus-visible {
outline: 2px solid #1c1b22;
outline-offset: 2px;
}
.rd.is-demo .rd__fill {
animation-name: rdFill;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rd.is-demo .rd__row--4 .rd__fill {
animation-delay: 0.16s;
}
.rd.is-demo .rd__row--3 .rd__fill {
animation-delay: 0.32s;
}
.rd.is-demo .rd__row--2 .rd__fill {
animation-delay: 0.48s;
}
.rd.is-demo .rd__row--1 .rd__fill {
animation-delay: 0.64s;
}
.rd.is-demo .rd__n,
.rd.is-demo .rd__c {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.rd.is-demo .rd__n--1, .rd.is-demo .rd__c--1 {
animation-name: rdStep1;
}
.rd.is-demo .rd__n--2, .rd.is-demo .rd__c--2 {
animation-name: rdStep2;
}
.rd.is-demo .rd__n--3, .rd.is-demo .rd__c--3 {
animation-name: rdStep3;
}
.rd.is-demo .rd__n--4, .rd.is-demo .rd__c--4 {
animation-name: rdStep4;
}
@keyframes rdFill {
0% {
transform: scaleX(0);
}
46%, 100% {
transform: scaleX(var(--p));
}
}
@keyframes rdStep1 {
0% {
opacity: 1;
}
22%, 100% {
opacity: 0;
}
}
@keyframes rdStep2 {
0% {
opacity: 0;
}
22% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes rdStep3 {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
84%, 100% {
opacity: 0;
}
}
@keyframes rdStep4 {
0% {
opacity: 0;
}
84%, 100% {
opacity: 1;
}
}
@media (max-width: 340px), (max-height: 220px) {
.rd {
width: 100%;
}
.rd__card {
gap: 12px;
padding: 12px;
}
.rd__n {
font-size: 26px;
}
.rd__stars {
gap: 0;
}
.rd__stars svg {
width: 11px;
height: 11px;
}
.rd__count {
font-size: 10px;
}
.rd__rows {
gap: 4px;
}
.rd__row {
gap: 4px;
font-size: 10px;
}
.rd__bg, .rd__fill {
height: 6px;
}
.rd__pct {
min-width: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.rd__fill, .rd__n, .rd__c {
animation: none !important;
}
.rd__n, .rd__c {
opacity: 0 !important;
}
.rd__n--4, .rd__c--4 {
opacity: 1 !important;
}
}(function () {
var root = document.querySelector('.rd');
var rows = [].slice.call(root.querySelectorAll('.rd__row'));
// 사람이 손을 대면 미리보기 루프를 끄고 정적 상태(최종 폭·최종 숫자)로 남긴다
function stopLoop() { root.classList.remove('is-demo'); }
// 별점 줄을 누르면 그 점수만 골라 보는 필터 — 상점 평점 블록이 실제로 하는 동작
rows.forEach(function (row) {
row.addEventListener('click', function () {
var was = row.getAttribute('aria-pressed') === 'true';
stopLoop();
rows.forEach(function (r) { r.setAttribute('aria-pressed', 'false'); });
row.setAttribute('aria-pressed', was ? 'false' : 'true');
root.classList.toggle('is-picked', !was);
});
});
root.addEventListener('pointerenter', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./RatingBars.module.scss";
/* ambient 에 React 이벤트 제네릭이 없어 파일 안에 필요한 만큼만 선언한다 */
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type RowVars = CSSProperties & { "--p"?: string };
export type RatingRow = {
/** 별점 라벨 — 5 부터 1 까지 */
level: string;
/** 막대가 차오를 목표 비율 (0~1) */
ratio: number;
/** 오른쪽에 보이는 퍼센트 */
pct: string;
/** 스크린 리더가 읽을 한 문장 */
label: string;
};
export type RatingBarsProps = {
sectionLabel?: string;
/** 카운트업으로 갈아 끼울 평균 점수 — 마지막이 최종값 */
scores?: string[];
/** 별 묶음이 읽히는 이름 */
scoreLabel?: string;
/** 카운트업으로 갈아 끼울 후기 개수 — 마지막이 최종값 */
counts?: string[];
rows?: RatingRow[];
/** 다섯 막대가 차례로 차오르는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 막대 채움 색 */
color?: string;
};
const Star = (): ReactNode => (
<svg viewBox="0 0 256 256" width="14" height="14" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M229.06,108.79l-48.7,42,14.88,62.79a8.4,8.4,0,0,1-12.52,9.17L128,189.09,73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6a8.36,8.36,0,0,1,15.48,0l24.62,59.6L224.27,94A8.46,8.46,0,0,1,229.06,108.79Z" opacity="0.2" />
<path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z" />
</svg>
);
const StarHalf = (): ReactNode => (
<svg viewBox="0 0 256 256" width="14" height="14" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M128,24V189.09L73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6A8.26,8.26,0,0,1,128,24Z" opacity="0.2" />
<path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.4,16.4,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26ZM69.09,215.94c-.15.09-.2.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42a.39.39,0,0,1-.13-.51c.09-.27.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l17-41.08V184.63ZM223.84,102.73l-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0L136,184.63V50.78l17,41.08a8,8,0,0,0,6.75,4.92l63.91,5.16c.16,0,.25,0,.34.29S224,102.63,223.84,102.73Z" />
</svg>
);
const SmallStar = (): ReactNode => (
<svg viewBox="0 0 256 256" width="10" height="10" fill="currentColor" aria-hidden="true" focusable="false">
<path d="M229.06,108.79l-48.7,42,14.88,62.79a8.4,8.4,0,0,1-12.52,9.17L128,189.09,73.28,222.74a8.4,8.4,0,0,1-12.52-9.17l14.88-62.79-48.7-42A8.46,8.46,0,0,1,31.73,94L95.64,88.8l24.62-59.6a8.36,8.36,0,0,1,15.48,0l24.62,59.6L224.27,94A8.46,8.46,0,0,1,229.06,108.79Z" opacity="0.2" />
<path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z" />
</svg>
);
const DEFAULT_SCORES = ["4.1", "4.3", "4.5", "4.7"];
const DEFAULT_COUNTS = ["후기 812개", "후기 1,140개", "후기 1,382개", "후기 1,509개"];
const DEFAULT_ROWS: RatingRow[] = [
{ level: "5", ratio: 0.68, pct: "68%", label: "5점 후기가 68퍼센트" },
{ level: "4", ratio: 0.21, pct: "21%", label: "4점 후기가 21퍼센트" },
{ level: "3", ratio: 0.07, pct: "7%", label: "3점 후기가 7퍼센트" },
{ level: "2", ratio: 0.03, pct: "3%", label: "2점 후기가 3퍼센트" },
{ level: "1", ratio: 0.01, pct: "1%", label: "1점 후기가 1퍼센트" },
];
export default function RatingBars({
sectionLabel = "별점 분포 요약",
scores = DEFAULT_SCORES,
scoreLabel = "5점 만점에 4.7점",
counts = DEFAULT_COUNTS,
rows = DEFAULT_ROWS,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#1c1b22",
}: RatingBarsProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [picked, setPicked] = useState<number>(-1);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const step = [styles.step1, styles.step2, styles.step3, styles.step4];
const lag = [styles.lag1, styles.lag2, styles.lag3, styles.lag4, styles.lag5];
/* 별점 줄을 누르면 그 점수만 골라 보는 필터 — 상점 평점 블록이 실제로 하는 동작 */
const pick = (i: number): void => {
setLoop(false);
setPicked(picked === i ? -1 : i);
};
const cls = [styles.rd, loop ? styles.isDemo : "", picked >= 0 ? styles.isPicked : ""]
.filter(Boolean)
.join(" ");
return (
<section
className={cls}
style={vars}
aria-label={sectionLabel}
onPointerEnter={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.card}>
<div className={styles.summary}>
<p className={styles.avg} role="img" aria-label={scoreLabel}>
<span className={styles.num}>
{scores.map((s: string, i: number) => (
<span className={`${styles.n} ${step[i % step.length]}`} key={s}>{s}</span>
))}
</span>
<span className={styles.stars}>
<Star /><Star /><Star /><Star /><StarHalf />
</span>
</p>
<p className={styles.count} role="img" aria-label={counts[counts.length - 1]}>
{counts.map((c: string, i: number) => (
<span className={`${styles.c} ${step[i % step.length]}`} key={c}>{c}</span>
))}
</p>
</div>
<ul className={styles.rows}>
{rows.map((r: RatingRow, i: number) => {
const rowVars: RowVars = { "--p": String(r.ratio) };
return (
<li key={r.level}>
{/* 「별점 라벨 · 트랙 · 퍼센트」 세 칸. 트랙과 채움은 같은 칸에 겹치므로
flex: 1 1 0 + scaleX 조합(BD19 오탐)을 쓰지 않는다 */}
<button
className={`${styles.row} ${lag[i] || ""}`}
type="button"
style={rowVars}
aria-pressed={picked === i}
aria-label={r.label}
onClick={() => pick(i)}
>
<span className={styles.lv} aria-hidden="true">{r.level}<SmallStar /></span>
<span className={styles.track} aria-hidden="true">
<span className={styles.bg} />
<span className={styles.fill} />
</span>
<span className={styles.pct} aria-hidden="true">{r.pct}</span>
</button>
</li>
);
})}
</ul>
</div>
</section>
);
}04 별점 분포 요약 바 — 측정 누적면적 1.737% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 고객 후기 디자인 패턴 9종, 신뢰를 보여 주는
이 데모가 속한 글
고객 후기 디자인 패턴 9종, 신뢰를 보여 주는고객 후기 디자인은 다른 사람이 남긴 평가를 화면에 얹어, 망설이는 방문자를 마지막으로 안심시키는 섹션입니다. 아홉 가지는 후기 카드·별점·고객사 목록을 HTML 과 CSS 만으로 지어서 옮겨 붙이면 그대로 움직입니 이 글의 데모 전부 보기


