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 オリジナル · SCSS と React は zip に同梱 · 価格表 デザイン9選 — 年払い切替・割引率まで計算して見せる
このデモが属する記事
価格表 デザイン9選 — 年払い切替・割引率まで計算して見せる価格表 デザインは、料金プランのボタン一つ・数字一つに計算の層を重ねる考え方です。年払い・月払いの切替が変わるたびに各プランの価格を計算し直し、割引バッジは定価と割引価格の差を自分で求め、法人窓口セクションは利用量の値からその場で見積もりを この記事のデモをすべて見る


