07Team quote card — reverse-computing line count to size the quote itself
Estimating the line count from the quote length, then dividing the target box height TARGET_H by that count times the line-height ratio read through getComputedStyle, works the largest font size that still fits backwards; it is clamped between MIN_FONT and MAX_FONT and pushed into a CSS variable. Long quotes shrink, short ones keep the larger size.
Best forTeam member testimonial cards, hiring-interview quotes
getComputedStyleline-heightCSS custom properties

<main class="stage">
<section class="quote" id="quote">
<p class="quote__hint" id="quoteHint">It divides the target box height by the computed line-height to work backward to a font size that fits the quote's length.</p>
<blockquote class="quote__box" id="quoteBox">
<p class="quote__text" id="quoteText"></p>
<footer class="quote__who" id="quoteWho"></footer>
</blockquote>
<p class="quote__meta"><span class="quote__chip" id="quoteChip">font 16px</span></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 600ms — 교체 등장
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-orange);
.quote {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: $sp-2;
align-items: center;
text-align: center;
}
.quote__hint {
margin: 0;
padding: 0 $sp-2;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-ink, .72);
}
.quote__box {
width: 100%;
margin: 0;
padding: $sp-3;
border-radius: $r-card;
background: rgba($subject-ink, .06);
opacity: .4;
transform: scale(.97);
transition: opacity $duration $easing, transform $duration $easing;
}
.quote__box.is-swap {
opacity: 1;
transform: scale(1);
}
.quote__text {
margin: 0 0 $sp-1;
font-weight: 600;
line-height: 1.45;
color: $subject-ink;
}
.quote__who {
font-weight: 500;
font-size: 11px;
color: rgba($subject-ink, .72);
}
.quote__meta { margin: 0; }
.quote__chip {
display: inline-flex;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.quote { max-width: 300px; gap: $sp-1; }
.quote__hint { font-size: 9px; }
.quote__box { padding: $sp-2; }
.quote__who { font-size: 10px; }
.quote__chip { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.quote__box {
transition: none;
opacity: 1;
transform: none;
}
}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);
}
.quote {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
text-align: center;
}
.quote__hint {
margin: 0;
padding: 0 8px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.quote__box {
width: 100%;
margin: 0;
padding: 12px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
opacity: 0.4;
transform: scale(0.97);
transition: opacity 600ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.quote__box.is-swap {
opacity: 1;
transform: scale(1);
}
.quote__text {
margin: 0 0 4px;
font-weight: 600;
line-height: 1.45;
color: #17141a;
}
.quote__who {
font-weight: 500;
font-size: 11px;
color: rgba(23, 20, 26, 0.72);
}
.quote__meta {
margin: 0;
}
.quote__chip {
display: inline-flex;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.quote {
max-width: 300px;
gap: 4px;
}
.quote__hint {
font-size: 9px;
}
.quote__box {
padding: 8px;
}
.quote__who {
font-size: 10px;
}
.quote__chip {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.quote__box {
transition: none;
opacity: 1;
transform: none;
}
}// 팀 인용문 카드(07) — getComputedStyle 로 읽은 line-height(배수)와 목표 칸 높이(TARGET_H)를
// 이용해 "글자크기 × line-height ≤ TARGET_H" 를 만족하는 최대 글자크기를 거꾸로 계산한다
// (계산 한 겹). 문구가 바뀔 때 opacity 크로스페이드로 겹쳐 보이지 않도록 steps(1,end) 로
// 한 프레임에 통째로 컷 교체한다.
(function () {
var box = document.getElementById('quoteBox');
var textEl = document.getElementById('quoteText');
var whoEl = document.getElementById('quoteWho');
var chip = document.getElementById('quoteChip');
if (!box || !textEl || !whoEl || !chip) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var QUOTES = [
{ text: 'One short line of feedback.', who: 'M1 · Design' },
{ text: 'While building the team page, what I cared about most was speed and a sense of trust.', who: 'M2 · Dev' },
{ text: 'So-so.', who: 'M3 · Ops' }
];
var idx = 0;
var TARGET_H = 64; // 칸 높이 목표(px) — 이 안에 인용문이 들어가야 한다
var MIN_FONT = 12;
var MAX_FONT = 20;
function lineHeightRatio() {
var cs = getComputedStyle(textEl);
var lh = parseFloat(cs.lineHeight);
var fs = parseFloat(cs.fontSize) || 16;
if (!lh || isNaN(lh)) return 1.4;
return lh / fs;
}
function fitFontSize(length) {
var ratio = lineHeightRatio();
var estLines = length > 40 ? 3 : length > 18 ? 2 : 1;
var maxByHeight = TARGET_H / (estLines * ratio);
var size = Math.max(MIN_FONT, Math.min(MAX_FONT, Math.floor(maxByHeight)));
return size;
}
function show(i) {
var q = QUOTES[i];
var size = fitFontSize(q.text.length);
textEl.style.fontSize = size + 'px';
textEl.textContent = q.text;
whoEl.textContent = q.who;
chip.textContent = 'font ' + size + 'px · line ' + lineHeightRatio().toFixed(1) + 'x';
box.classList.remove('is-swap');
void box.offsetWidth;
box.classList.add('is-swap');
}
show(idx);
if (!reduce) {
setInterval(function () {
idx = (idx + 1) % QUOTES.length;
show(idx);
}, 420);
}
})();// TeamTestimonial — vanilla 판(run/338/demo/07-team-testimonial/index.html) 의 React 포트.
// 계산된 줄높이(line-height)를 목표 칸 높이로 나눠, 인용문 길이에 맞는 글자 크기를
// 거꾸로 구한다(계산 한 겹).
import { useEffect, useRef } from "react";
import styles from "./TeamTestimonial.module.scss";
export type TeamTestimonialProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "계산된 줄높이(line-height)를 목표 칸 높이로 나눠, 인용문 길이에 맞는 글자 크기를 거꾸로 구합니다";
type Quote = { text: string; who: string };
const QUOTES: Quote[] = [
{ text: "짧은 소감 한 줄.", who: "M1 · 디자인" },
{ text: "팀 소개 페이지를 만들면서 가장 신경 쓴 건 속도와 신뢰감이었어요.", who: "M2 · 개발" },
{ text: "보통.", who: "M3 · 운영" }
];
const TARGET_H = 64;
const MIN_FONT = 12;
const MAX_FONT = 20;
function lineHeightRatio(textEl: HTMLElement): number {
const cs = window.getComputedStyle(textEl);
const fontSize = parseFloat(cs.fontSize) || 16;
const lineHeight = parseFloat(cs.lineHeight);
if (!lineHeight || Number.isNaN(lineHeight)) return 1.4;
return lineHeight / fontSize;
}
export default function TeamTestimonial({
duration = "600ms",
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a"
}: TeamTestimonialProps) {
const boxRef = useRef<HTMLQuoteElement | null>(null);
const textRef = useRef<HTMLParagraphElement | null>(null);
const whoRef = useRef<HTMLElement | null>(null);
const chipRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const box = boxRef.current;
const textEl = textRef.current;
const whoEl = whoRef.current;
const chip = chipRef.current;
if (!box || !textEl || !whoEl || !chip) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function fitFontSize(length: number): number {
const estLines = length > 40 ? 3 : length > 18 ? 2 : 1;
const ratio = lineHeightRatio(textEl!);
const maxByHeight = TARGET_H / (estLines * ratio);
return Math.max(MIN_FONT, Math.min(MAX_FONT, Math.floor(maxByHeight)));
}
function show(i: number): void {
const q = QUOTES[i];
const size = fitFontSize(q.text.length);
textEl!.style.fontSize = size + "px";
textEl!.textContent = q.text;
whoEl!.textContent = q.who;
chip!.textContent = "font " + size + "px · line " + lineHeightRatio(textEl!).toFixed(1) + "x";
box!.classList.remove(styles.isSwap);
void box!.offsetWidth;
box!.classList.add(styles.isSwap);
}
let idx = 0;
show(0);
let timer = 0;
if (!reduce) {
timer = window.setInterval(() => {
idx = (idx + 1) % QUOTES.length;
show(idx);
}, 420);
}
return () => {
if (timer) window.clearInterval(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<section className={styles.quote}>
<p className={styles.hint}>{HINT}</p>
<blockquote className={styles.box} ref={boxRef}>
<p className={styles.text} ref={textRef} />
<footer className={styles.who} ref={whoRef} />
</blockquote>
<p className={styles.meta}>
<span className={styles.chip} ref={chipRef} style={{ color }}>font 16px</span>
</p>
</section>
</main>
);
}07 Team quote card — reverse-computing line count to size the quote itself — Measured area 19.911% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · 9 Team Page Design Patterns (Copy-Paste)
The post this demo lives in
9 Team Page Design Patterns (Copy-Paste)Nine team-page sections that compute their layout: columns balance by headcount, bios get read-more only past overflow, See all demos in this post
If you like this effect - similar demos
Landing sections04 Quote block — a quote card that reverse-solves its own font size from character length9 Case Study Page Design Sections (Copy-Paste)
Landing sections06 Source line revealing the speaker a beat lateBlockquote CSS: 9 Quote Box Designs (Copy-Paste)
Landing sections06 Quote testimonial9 Project Showcase Section Templates — Copy-Paste