06요금 후기 — 평점 평균을 직접 구해 보여주는 후기 묶음
후기마다 매긴 평점을 모아 평균을 직접 구해 큰 숫자로 보여주고, 각 후기 글의 길이를 재서 카드 높이를 그 길이에 맞춰 다시 정합니다.
쓰임요금제 페이지 신뢰 섹션, 고객 후기 묶음 카드
requestAnimationFramescrollHeightline-clamp

<main class="stage">
<section class="panel">
<p class="hint">별점 평균과 카드 높이를 후기 내용에 맞춰 다시 계산합니다</p>
<p class="avgLine"><span class="avgNum" id="avgNum">0.0</span><span class="avgUnit"> / 5 · <span id="avgCount">0</span>명</span></p>
<div class="cards" id="cards">
<article class="card" id="card0">
<p class="quote" id="quote0"></p>
<p class="who" id="who0"></p>
</article>
<article class="card" id="card1">
<p class="quote" id="quote1"></p>
<p class="who" id="who1"></p>
</article>
<article class="card" id="card2">
<p class="quote" id="quote2"></p>
<p class="who" id="who2"></p>
</article>
</div>
<p class="chip" id="heightChip">카드 높이 0px</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.panel {
width: 100%;
max-width: 440px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.hint {
margin: 0;
padding: 0 $sp-2;
text-align: center;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-ink, .72);
}
.avgLine {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-1;
}
.avgNum {
font-weight: 700;
font-size: 26px;
color: $subject-ink;
}
.avgUnit {
font-weight: 400;
font-size: 11px;
color: rgba($subject-ink, .70);
}
.cards {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
}
.card {
border-radius: $r-card;
background: rgba($subject-ink, .05);
padding: $sp-2;
display: flex;
flex-direction: column;
gap: $sp-1;
transition: min-height $duration $easing;
}
.quote {
margin: 0;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
color: $subject-ink;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.who {
margin: 0;
font-weight: 400;
font-size: 9px;
color: rgba($subject-ink, .60);
}
.chip {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.panel { max-width: 300px; gap: $sp-1; }
.hint { font-size: 9px; }
.cards { gap: $sp-1; }
.card { padding: $sp-1; }
.quote { font-size: 8px; -webkit-line-clamp: 2; }
.who { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.card {
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);
}
.panel {
width: 100%;
max-width: 440px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.hint {
margin: 0;
padding: 0 8px;
text-align: center;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.avgLine {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
}
.avgNum {
font-weight: 700;
font-size: 26px;
color: #17141a;
}
.avgUnit {
font-weight: 400;
font-size: 11px;
color: rgba(23, 20, 26, 0.7);
}
.cards {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.card {
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
transition: min-height 220ms ease-out;
}
.quote {
margin: 0;
font-weight: 500;
font-size: 10px;
line-height: 1.4;
color: #17141a;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.who {
margin: 0;
font-weight: 400;
font-size: 9px;
color: rgba(23, 20, 26, 0.6);
}
.chip {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.panel {
max-width: 300px;
gap: 4px;
}
.hint {
font-size: 9px;
}
.cards {
gap: 4px;
}
.card {
padding: 4px;
}
.quote {
font-size: 8px;
-webkit-line-clamp: 2;
}
.who {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}// 고객 리뷰(06) — 후기 3개의 별점 평균을 매번 새로 구하고, 각 카드의 실제 콘텐츠 높이(scrollHeight)를
// 다시 재 가장 큰 값으로 세 카드의 높이를 맞춘다(계산 한 겹). 자동 시연은 후기 길이가 다른 세트를 돌린다.
(function () {
var avgNum = document.getElementById('avgNum');
var avgCount = document.getElementById('avgCount');
var cards = [document.getElementById('card0'), document.getElementById('card1'), document.getElementById('card2')];
var quotes = [document.getElementById('quote0'), document.getElementById('quote1'), document.getElementById('quote2')];
var whos = [document.getElementById('who0'), document.getElementById('who1'), document.getElementById('who2')];
var chip = document.getElementById('heightChip');
if (!avgNum || !avgCount || !chip || cards.indexOf(null) !== -1 || quotes.indexOf(null) !== -1 || whos.indexOf(null) !== -1) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SETS = [
{
count: 128,
reviews: [
{ text: '요금이 명확해서 좋아요.', who: '리본 스튜디오 · 지수', rating: 5 },
{ text: '전환이 쉬웠고 안내가 친절했습니다.', who: '블룸웍스 · 민호', rating: 4 },
{ text: '팀 플랜으로 옮긴 뒤 관리가 편해졌어요.', who: '노트그린 · 다은', rating: 5 }
]
},
{
count: 96,
reviews: [
{ text: '가격 대비 기능이 많아 만족합니다. 특히 연간 결제 혜택이 커요.', who: '파랑상점 · 세훈', rating: 4 },
{ text: '지원팀 응답이 빨랐어요.', who: '한들컴퍼니 · 유나', rating: 5 },
{ text: '무료 체험 기간 동안 충분히 써보고 결정할 수 있어서 좋았습니다.', who: '다솔웍스 · 재현', rating: 4 }
]
},
{
count: 210,
reviews: [
{ text: '비교표가 있어서 플랜 고르기가 쉬웠어요.', who: '연두하우스 · 하은', rating: 5 },
{ text: '결제 수단이 다양해서 편했습니다.', who: '가온스퀘어 · 도윤', rating: 5 },
{ text: '연간 절약액이 커서 바로 전환했어요. 만족스럽습니다.', who: '별빛랩 · 서아', rating: 4 }
]
}
];
function average(reviews) {
var sum = reviews.reduce(function (acc, r) { return acc + r.rating; }, 0);
return sum / reviews.length;
}
function apply(set) {
avgNum.textContent = average(set.reviews).toFixed(1);
avgCount.textContent = String(set.count);
set.reviews.forEach(function (r, i) {
quotes[i].textContent = '“' + r.text + '”';
whos[i].textContent = r.who + ' · ' + r.rating + '★';
});
requestAnimationFrame(function () {
var maxH = 0;
cards.forEach(function (card) { maxH = Math.max(maxH, card.scrollHeight); });
cards.forEach(function (card) { card.style.minHeight = maxH + 'px'; });
chip.textContent = '카드 높이 ' + maxH + 'px';
});
}
apply(SETS[0]);
if (!reduce) {
var idx = 0;
setInterval(function () {
idx = (idx + 1) % SETS.length;
apply(SETS[idx]);
}, 900);
}
})();// PricingTestimonial — vanilla 판(run/339/demo/06-pricing-testimonial/index.html) 의 React 포트.
// 후기 3개의 별점 평균을 매번 새로 구하고, 각 카드의 실제 콘텐츠 높이(scrollHeight)를 다시 재
// 가장 큰 값으로 세 카드의 높이를 맞춘다(계산 한 겹).
import { useEffect, useRef } from "react";
import styles from "./PricingTestimonial.module.scss";
export type PricingTestimonialProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "별점 평균과 카드 높이를 후기 내용에 맞춰 다시 계산합니다";
type Review = { text: string; who: string; rating: number };
type ReviewSet = { count: number; reviews: Review[] };
const SETS: ReviewSet[] = [
{
count: 128,
reviews: [
{ text: "요금이 명확해서 좋아요.", who: "리본 스튜디오 · 지수", rating: 5 },
{ text: "전환이 쉬웠고 안내가 친절했습니다.", who: "블룸웍스 · 민호", rating: 4 },
{ text: "팀 플랜으로 옮긴 뒤 관리가 편해졌어요.", who: "노트그린 · 다은", rating: 5 }
]
},
{
count: 96,
reviews: [
{ text: "가격 대비 기능이 많아 만족합니다. 특히 연간 결제 혜택이 커요.", who: "파랑상점 · 세훈", rating: 4 },
{ text: "지원팀 응답이 빨랐어요.", who: "한들컴퍼니 · 유나", rating: 5 },
{ text: "무료 체험 기간 동안 충분히 써보고 결정할 수 있어서 좋았습니다.", who: "다솔웍스 · 재현", rating: 4 }
]
},
{
count: 210,
reviews: [
{ text: "비교표가 있어서 플랜 고르기가 쉬웠어요.", who: "연두하우스 · 하은", rating: 5 },
{ text: "결제 수단이 다양해서 편했습니다.", who: "가온스퀘어 · 도윤", rating: 5 },
{ text: "연간 절약액이 커서 바로 전환했어요. 만족스럽습니다.", who: "별빛랩 · 서아", rating: 4 }
]
}
];
function average(reviews: Review[]): number {
const sum = reviews.reduce((acc, r) => acc + r.rating, 0);
return sum / reviews.length;
}
export default function PricingTestimonial({
duration = "220ms",
easing = "ease-out",
color = "#0a4fe8"
}: PricingTestimonialProps) {
const avgNumRef = useRef<HTMLSpanElement | null>(null);
const avgCountRef = useRef<HTMLSpanElement | null>(null);
const cardRefs = useRef<(HTMLElement | null)[]>([]);
const quoteRefs = useRef<(HTMLParagraphElement | null)[]>([]);
const whoRefs = useRef<(HTMLParagraphElement | null)[]>([]);
const chipRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const avgNum = avgNumRef.current;
const avgCount = avgCountRef.current;
const chip = chipRef.current;
if (!avgNum || !avgCount || !chip) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(set: ReviewSet): void {
avgNum!.textContent = average(set.reviews).toFixed(1);
avgCount!.textContent = String(set.count);
set.reviews.forEach((r, i) => {
const quote = quoteRefs.current![i];
const who = whoRefs.current![i];
if (quote) quote.textContent = "“" + r.text + "”";
if (who) who.textContent = r.who + " · " + r.rating + "★";
});
requestAnimationFrame(() => {
let maxH = 0;
cardRefs.current!.forEach((card) => {
if (card) maxH = Math.max(maxH, card.scrollHeight);
});
cardRefs.current!.forEach((card) => {
if (card) card.style.minHeight = maxH + "px";
});
chip!.textContent = "카드 높이 " + maxH + "px";
});
}
apply(SETS[0]);
let timer = 0;
if (!reduce) {
let idx = 0;
timer = window.setInterval(() => {
idx = (idx + 1) % SETS.length;
apply(SETS[idx]);
}, 900);
}
return () => {
if (timer) window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<section className={styles.panel}>
<p className={styles.hint}>{HINT}</p>
<p className={styles.avgLine}>
<span className={styles.avgNum} ref={avgNumRef}>
0.0
</span>
<span className={styles.avgUnit}>
{" "}
/ 5 · <span ref={avgCountRef}>0</span>명
</span>
</p>
<div className={styles.cards}>
{[0, 1, 2].map((i) => (
<article
key={i}
className={styles.card}
ref={(el: HTMLElement | null) => {
cardRefs.current![i] = el;
}}
>
<p
className={styles.quote}
ref={(el: HTMLParagraphElement | null) => {
quoteRefs.current![i] = el;
}}
/>
<p
className={styles.who}
ref={(el: HTMLParagraphElement | null) => {
whoRefs.current![i] = el;
}}
/>
</article>
))}
</div>
<p className={styles.chip} ref={chipRef}>
카드 높이 0px
</p>
</section>
</main>
);
}06 요금 후기 — 평점 평균을 직접 구해 보여주는 후기 묶음 — 측정 누적면적 19.763% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 가격표 디자인 UI 9종 — 연/월 전환·할인율까지 계산해서 보여주는
이 데모가 속한 글
가격표 디자인 UI 9종 — 연/월 전환·할인율까지 계산해서 보여주는연/월 전환이 바뀔 때마다 플랜마다 가격을 다시 계산해 보여주고, 할인 배지는 원가와 할인가 차이를 직접 구하고, 기업 문의 섹션은 사용량 값으로 예상 비용을 즉석에서 계산하는 가격표 페이지 9종입니다. 가격표 디자 이 글의 데모 전부 보기


