04Quote block — a quote card that reverse-solves its own font size from character length
Counting the quote's character length works backward to the largest font size that still fits one line, and longer quotes also tighten the line spacing to keep everything inside the card.
Best forClient quote cards, testimonial highlight sections
getContext('2d')measureTextclassList.add

<main class="stage">
<section class="panel">
<p class="hint" id="hint">The quote is measured on a real canvas, and the font size that fits the target width is solved backward every time.</p>
<blockquote class="quote">
<p class="quoteText" id="quoteText">This one card component cut our design review time in half.</p>
</blockquote>
<p class="quoteAuthor" id="quoteAuthor">— Seoyeon Kim, Atlas Product Lead</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: 'This one card component cut our design review time in half.', author: '— Seoyeon Kim, Atlas Product Lead' },
{ text: 'After we rebuilt the onboarding screen, first-week churn dropped noticeably.', author: '— Doyoon Park, Cobalt Growth Team' },
{ text: 'With the whole team using the same component, review arguments disappeared.', author: '— Haeun Lee, Silverline Design System' }
];
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 Quote block — a quote card that reverse-solves its own font size from character length — Measured area 7.081% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · 9 Case Study Page Design Sections (Copy-Paste)
The post this demo lives in
9 Case Study Page Design Sections (Copy-Paste)Nine case-study sections that compute: the hero recounts its metric frame by frame, tiles size from a computed impact sc See all demos in this post
If you like this effect - similar demos
Landing sections06 Partner logo bar — a scroll that reverse-computes its speed from the measured track width9 Team Page Design Patterns (Copy-Paste)
Landing sections03 Billing toggle — a switch that derives its own savings badge from its position9 Pricing Page Design Sections (Copy-Paste)
Landing sections03 Case study block9 Project Showcase Section Templates — Copy-Paste