04引用ブロック — 文字数からフォントサイズを逆算する引用カード
引用文の文字数を数えて一行に収まる最大のフォントサイズを逆算し、文章が長いほど行間も詰めてカード内に収めます。
使いどころクライアント引用カード、レビュー強調セクション
getContext('2d')measureTextclassList.add

<main class="stage">
<section class="panel">
<p class="hint" id="hint">引用文を実際のキャンバスで測り、目標の幅に合う文字サイズをその都度逆算して求めます</p>
<blockquote class="quote">
<p class="quoteText" id="quoteText">このカードコンポーネント一つでデザインレビューの時間が半分になりました。</p>
</blockquote>
<p class="quoteAuthor" id="quoteAuthor">— キム・ソヨン、Atlas プロダクトリード</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-paper);
.panel {
position: relative;
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
text-align: center;
}
.hint {
margin: 0;
color: rgba($subject-ink, .72);
font-weight: 400;
font-size: 10px;
line-height: 1.35;
}
.quote {
position: relative;
margin: 0;
padding: $sp-4 $sp-3;
border-radius: $r-card;
background: rgba($color, .06);
}
.quote::before {
content: "\201C";
position: absolute;
top: -$sp-2;
left: $sp-3;
font-size: 40px;
font-weight: 700;
line-height: 1;
color: rgba($color, .72);
}
.quoteText {
position: relative;
z-index: 1;
margin: 0;
color: $subject-ink;
font-weight: 600;
line-height: 1.5;
font-size: 16px;
transform-origin: center;
}
.quoteText--swap {
animation: quotePop $duration $easing;
}
.quoteAuthor {
margin: 0;
color: $color;
font-weight: 600;
font-size: 11px;
}
@keyframes quotePop {
0% { opacity: 0; transform: scale(.96); }
100% { opacity: 1; transform: scale(1); }
}
@media (max-width: 340px) {
.panel { max-width: 280px; gap: $sp-2; }
.quote { padding: $sp-3 $sp-2; }
.quoteText { font-size: 13px; }
.quoteAuthor { font-size: 10px; }
.hint { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.quoteText--swap {
animation: none;
}
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
position: relative;
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 400;
font-size: 10px;
line-height: 1.35;
}
.quote {
position: relative;
margin: 0;
padding: 16px 12px;
border-radius: 16px;
background: rgba(10, 79, 232, 0.06);
}
.quote::before {
content: "“";
position: absolute;
top: -8px;
left: 12px;
font-size: 40px;
font-weight: 700;
line-height: 1;
color: rgba(10, 79, 232, 0.72);
}
.quoteText {
position: relative;
z-index: 1;
margin: 0;
color: #17141a;
font-weight: 600;
line-height: 1.5;
font-size: 16px;
transform-origin: center;
}
.quoteText--swap {
animation: quotePop 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.quoteAuthor {
margin: 0;
color: #0a4fe8;
font-weight: 600;
font-size: 11px;
}
@keyframes quotePop {
0% {
opacity: 0;
transform: scale(0.96);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 340px) {
.panel {
max-width: 280px;
gap: 8px;
}
.quote {
padding: 12px 8px;
}
.quoteText {
font-size: 13px;
}
.quoteAuthor {
font-size: 10px;
}
.hint {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.quoteText--swap {
animation: none;
}
}// 인용 블록(04) — 인용문 길이가 매번 다르므로, 목표 폭(카드 안쪽 폭)에 맞는 글자 크기를
// canvas measureText 로 실제 렌더 폭을 재보며 이분 탐색으로 거꾸로 풀어 찾는다(계산 한 겹).
// 최대 크기에서 시작해 넘치면 줄이고, 남으면 최대치까지 다시 키운다 — 고정 크기 하드코딩이 아니다.
(function () {
var textEl = document.getElementById('quoteText');
var authorEl = document.getElementById('quoteAuthor');
if (!textEl || !authorEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var QUOTES = [
{ text: 'このカードコンポーネント一つでデザインレビューの時間が半分になりました。', author: '— キム・ソヨン、Atlas プロダクトリード' },
{ text: 'オンボーディング画面を作り直してから、初週の離脱が目に見えて減りました。', author: '— パク・ドユン、Cobalt グロースチーム' },
{ text: 'チーム全体が同じコンポーネントを使うことで、レビューでの言い争いがなくなりました。', author: '— イ・ハウン、Silverline デザインシステム' }
];
var MIN_PX = 12;
var MAX_PX = 20;
var TOLERANCE = 1;
var cv = document.createElement('canvas');
var ctx = cv.getContext('2d');
function widthAt(text, px) {
if (!ctx) return text.length * px * 0.56;
ctx.font = '600 ' + px + 'px -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
return ctx.measureText(text).width;
}
// 목표 폭 안에 들어가는 가장 큰 글자 크기를 이분 탐색으로 거꾸로 푼다.
function solveFontSize(text, targetWidth) {
var lo = MIN_PX, hi = MAX_PX, best = MIN_PX;
for (var i = 0; i < 14; i++) {
var mid = (lo + hi) / 2;
var w = widthAt(text, mid);
if (w <= targetWidth - TOLERANCE) {
best = mid;
lo = mid;
} else {
hi = mid;
}
}
return best;
}
function applyQuote(quote) {
var targetWidth = textEl.clientWidth * 2.6; // 두 줄 남짓으로 접힐 총 글자폭 예산
var px = solveFontSize(quote.text, targetWidth);
textEl.style.fontSize = px.toFixed(1) + 'px';
textEl.textContent = quote.text;
authorEl.textContent = quote.author;
}
var idx = 0;
applyQuote(QUOTES[0]);
if (!reduce) {
setInterval(function () {
idx = (idx + 1) % QUOTES.length;
textEl.classList.remove('quoteText--swap');
void textEl.offsetWidth;
applyQuote(QUOTES[idx]);
textEl.classList.add('quoteText--swap');
}, 700);
}
})();// CaseQuoteBlock — vanilla 판(run/340/demo/04-case-quote-block/index.html) 의 React 포트.
// 인용문 길이가 매번 다르므로 목표 폭에 맞는 글자 크기를 canvas measureText 로 실측하며
// 이분 탐색으로 거꾸로 풀어 찾는다(고정 크기 하드코딩이 아니다).
import { useEffect, useRef } from "react";
import styles from "./CaseQuoteBlock.module.scss";
export type CaseQuoteBlockProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "인용문을 실제 캔버스로 재 보고, 목표 폭에 맞는 글자 크기를 거꾸로 풀어서 매번 다시 구합니다";
type Quote = { text: string; author: string };
const QUOTES: Quote[] = [
{ text: "이 카드 컴포넌트 하나로 디자인 검토 시간이 절반으로 줄었습니다.", author: "— 김서연, 아틀라스 프로덕트 리드" },
{ text: "온보딩 화면을 새로 짜고 나서 첫 주 이탈이 눈에 띄게 줄었어요.", author: "— 박도윤, 코발트 그로스팀" },
{ text: "팀 전체가 같은 컴포넌트를 쓰니 리뷰에서 다툴 일이 사라졌습니다.", author: "— 이하은, 실버라인 디자인 시스템" }
];
const MIN_PX = 12;
const MAX_PX = 20;
const TOLERANCE = 1;
export default function CaseQuoteBlock({
duration = "300ms",
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#0a4fe8"
}: CaseQuoteBlockProps) {
const textRef = useRef<HTMLParagraphElement | null>(null);
const authorRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const textEl = textRef.current;
const authorEl = authorRef.current;
if (!textEl || !authorEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const cv = document.createElement("canvas");
const ctx = cv.getContext("2d");
function widthAt(text: string, px: number): number {
if (!ctx) return text.length * px * 0.56;
ctx.font = "600 " + px + 'px -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
return ctx.measureText(text).width;
}
function solveFontSize(text: string, targetWidth: number): number {
let lo = MIN_PX;
let hi = MAX_PX;
let best = MIN_PX;
for (let i = 0; i < 14; i++) {
const mid = (lo + hi) / 2;
const w = widthAt(text, mid);
if (w <= targetWidth - TOLERANCE) {
best = mid;
lo = mid;
} else {
hi = mid;
}
}
return best;
}
function applyQuote(quote: Quote): void {
const targetWidth = textEl!.clientWidth * 2.6;
const px = solveFontSize(quote.text, targetWidth);
textEl!.style.fontSize = px.toFixed(1) + "px";
textEl!.textContent = quote.text;
authorEl!.textContent = quote.author;
}
let idx = 0;
applyQuote(QUOTES[0]);
let timer = 0;
if (!reduce) {
timer = window.setInterval(() => {
idx = (idx + 1) % QUOTES.length;
textEl!.classList.remove(styles.quoteTextSwap);
void textEl!.offsetWidth;
applyQuote(QUOTES[idx]);
textEl!.classList.add(styles.quoteTextSwap);
}, 700);
}
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>
<blockquote className={styles.quote}>
<p className={styles.quoteText} ref={textRef}>이 카드 컴포넌트 하나로 디자인 검토 시간이 절반으로 줄었습니다.</p>
</blockquote>
<p className={styles.quoteAuthor} ref={authorRef}>— 김서연, 아틀라스 프로덕트 리드</p>
</section>
</main>
);
}04 引用ブロック — 文字数からフォントサイズを逆算する引用カード — 計測 累積面積 7.081% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · 導入事例 web デザイン9選 — 成果数値まで計算して見せる
このデモが属する記事
導入事例 web デザイン9選 — 成果数値まで計算して見せる導入事例 web デザインは色や余白より先に、数字がどう動くかで差がつきます。画面に入った瞬間から成果数値がフレームごとに求め直されカウントアップし、事例ごとに計算したインパクトスコアでグリッドタイルの大きさが決まり、引用文の長さに合わせて この記事のデモをすべて見る


