07見出し・本文ペアプレビュー
見出し書体と本文書体をそれぞれ選ぶと組み合わせが即座に描画されます。イタリック・字間・行間まで、組み合わせが合うか見極めるツマミを1つのパネルに集めました。
使いどころランディングページ制作前の書体組み合わせ検討
font-familyfont-styleline-height

<main class="stage">
<div class="pp is-demo" id="pp">
<div class="pp__row">
<span class="pp__glabel">見出し</span>
<div class="pp__chips" role="group" aria-label="見出し書体を選ぶ">
<button class="pp__chip pp__chip--sans is-on" type="button" data-row="head" aria-pressed="true"><span class="pp__spec" aria-hidden="true">Aa</span>サンセリフ</button>
<button class="pp__chip pp__chip--serif" type="button" data-row="head" aria-pressed="false"><span class="pp__spec" aria-hidden="true">Aa</span>セリフ</button>
<button class="pp__chip pp__chip--mono" type="button" data-row="head" aria-pressed="false"><span class="pp__spec" aria-hidden="true">Aa</span>モノ</button>
<button class="pp__chip pp__chip--wide" type="button" data-row="head" aria-pressed="false"><span class="pp__spec" aria-hidden="true">Aa</span>ワイドサンセリフ</button>
</div>
</div>
<div class="pp__row">
<span class="pp__glabel">本文</span>
<div class="pp__chips" role="group" aria-label="本文書体を選ぶ">
<button class="pp__chip pp__chip--sans is-on" type="button" data-row="body" aria-pressed="true"><span class="pp__spec" aria-hidden="true">Aa</span>サンセリフ</button>
<button class="pp__chip pp__chip--serif" type="button" data-row="body" aria-pressed="false"><span class="pp__spec" aria-hidden="true">Aa</span>セリフ</button>
<button class="pp__chip pp__chip--mono" type="button" data-row="body" aria-pressed="false"><span class="pp__spec" aria-hidden="true">Aa</span>モノ</button>
<button class="pp__chip pp__chip--wide" type="button" data-row="body" aria-pressed="false"><span class="pp__spec" aria-hidden="true">Aa</span>ワイドサンセリフ</button>
</div>
</div>
<div class="pp__knobs">
<button class="pp__ital" id="ital" type="button" aria-pressed="false">イタリック</button>
<label class="pp__knob">字間
<input class="pp__range" id="ls" type="range" min="-0.02" max="0.12" step="0.01" value="0" aria-label="見出しの字間">
<output class="pp__kval" id="lsVal">0.00em</output>
</label>
<label class="pp__knob">行間
<input class="pp__range" id="lh" type="range" min="1.2" max="2" step="0.1" value="1.5" aria-label="本文の行間">
<output class="pp__kval" id="lhVal">1.5</output>
</label>
</div>
<div class="pp__sample">
<h2 class="pp__head" id="head">春風の便り</h2>
<p class="pp__body" id="body"><span class="pp__b1">見出しと本文がひとつの声を出すと、ページは静かになります。</span><br><span class="pp__b2">組み合わせが合うかは3行読めば分かります。</span></p>
</div>
<p class="pp__note">2つのチップ行でそれぞれ選ぶと組み合わせがすぐ描画されます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 제목·본문 페어링 프리뷰 — 웹폰트 한 줄 없이 시스템 스택만으로 짝을 견준다.
// 두 칩줄(제목·본문)이 각자 font-family 를 고르고, 기울임·자간·행간 손잡이가 한 패널에 붙는다.
// 서체는 보간되지 않는 값이라 미리보기 루프에서도 steps 컷처럼 딱 바뀐다
// (실측 2026-09-13: Chrome 152 에서 font-family 키프레임은 이산 교체, 경계에 중간 유령이 없다).
// 이 편의 주제 자체가 "웹폰트 없이" 이므로 서체는 OS 가 기본으로 주는 스택만 쓴다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
$mono: ui-monospace, "Cascadia Mono", Consolas, "Courier New", monospace;
$chip-line: rgba(23, 20, 26, .30);
$track: rgba(23, 20, 26, .16);
@include stage($stage-orange);
.pp {
--f-sans: system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
--f-serif: Georgia, "Times New Roman", ui-serif, serif;
--f-mono: ui-monospace, "Cascadia Mono", Consolas, "Courier New", monospace;
--f-wide: Verdana, "Segoe UI", "Noto Sans KR", sans-serif;
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 432px);
padding: $sp-2; border: 1px solid rgba(23, 20, 26, .14); border-radius: $r-card;
background: $stage-paper; box-shadow: $shadow-raised;
}
// ── 칩줄 두 개 (제목·본문)
.pp__row { display: flex; align-items: center; gap: $sp-2; }
.pp__glabel { flex: 0 0 auto; color: $subject-ink; font-weight: 700; font-size: 11px; line-height: 1.4; }
.pp__chips { display: flex; flex-wrap: wrap; gap: $sp-1; flex: 1 1 auto; min-width: 0; }
.pp__chip {
flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border: 1px solid $chip-line; border-radius: $r-pill;
background: transparent; color: $subject-ink; cursor: pointer;
font-weight: 600; font-size: 11px; line-height: 1.4;
}
// 칩 글자는 그 서체 자체로 찍힌다 — 이름과 견본(Aa)이 함께 그 서체의 얼굴이 된다
.pp__chip--sans { font-family: var(--f-sans); }
.pp__chip--serif { font-family: var(--f-serif); }
.pp__chip--mono { font-family: var(--f-mono); }
.pp__chip--wide { font-family: var(--f-wide); }
.pp__spec { font-weight: 700; font-size: 12px; line-height: 1; }
.pp__chip.is-on { background: $color; border-color: $color; color: $subject-cream; }
.pp__chip:focus-visible, .pp__ital:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// ── 손잡이 패널: 기울임 토글 + 자간·행간 슬라이더
.pp__knobs { display: flex; flex-wrap: wrap; align-items: center; gap: $sp-2; }
.pp__ital {
flex: 0 0 auto; padding: $sp-1 $sp-2; border: 1px solid $chip-line; border-radius: $r-pill;
background: transparent; color: $subject-ink; cursor: pointer;
font-weight: 600; font-size: 11px; line-height: 1.4;
}
.pp__ital.is-on { background: $color; border-color: $color; color: $subject-cream; }
.pp__knob { display: inline-flex; align-items: center; gap: $sp-1; color: $subject-ink; font-weight: 600; font-size: 11px; line-height: 1.4; }
.pp__kval { font-family: $mono; color: $subject-orange-deep; font-weight: 700; font-size: 11px; line-height: 1.4; }
.pp__range { appearance: none; -webkit-appearance: none; width: 84px; height: 16px; background: transparent; cursor: pointer; }
.pp__range::-webkit-slider-runnable-track { height: 4px; border-radius: $r-pill; background: $track; }
.pp__range::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 12px; height: 12px; margin-top: -4px;
border: 0; border-radius: 50%; background: $subject-blue-deep;
}
.pp__range::-moz-range-track { height: 4px; border-radius: $r-pill; background: $track; }
.pp__range::-moz-range-thumb { width: 12px; height: 12px; border: 0; border-radius: 50%; background: $subject-blue-deep; }
// ── 견본 판: 고른 짝이 즉시 렌더된다
.pp__sample { display: flex; flex-direction: column; gap: $sp-1; padding: $sp-2; border-radius: $r-sm; background: rgba(23, 20, 26, .05); }
.pp__head {
margin: 0; color: $subject-ink;
font-family: var(--f-sans); font-weight: 800; font-size: clamp(18px, 5vw, 24px);
line-height: 1.25; letter-spacing: 0;
}
.pp__body {
margin: 0; color: $subject-ink;
font-family: var(--f-sans); font-weight: 400; font-size: 13px; line-height: 1.5;
}
.pp__note { margin: 0; color: rgba(23, 20, 26, .78); font-weight: 500; font-size: 11px; line-height: 1.4; }
// ── 격자 미리보기 루프 (2초)
// 여덟 구간마다 짝이 바뀐다: 산/산 → 세리프/산 → 세리프/넓은 → 모노/세리프(기울임) →
// 모노/모노(기울임·자간) → 산/모노(기울임·자간) → 넓은/산(행간) → 산/산.
// 서체·기울임·자간·행간 모두 경계 양쪽에 같은 값을 두 번 찍어 컷으로 바뀌게 한다(0.001% 차이).
.pp.is-demo .pp__head {
animation-name: ppHead; animation-duration: $dur-loop; animation-timing-function: linear;
animation-iteration-count: infinite;
}
.pp.is-demo .pp__body {
animation-name: ppBody; animation-duration: $dur-loop; animation-timing-function: linear;
animation-iteration-count: infinite;
}
// 칩은 켜질 때 배경 반전 + 위로 2px 살짝 떴다 앉는다 — 유령 없이 선택 이동을 보여 준다
.pp.is-demo .pp__chip { animation-duration: $dur-loop; animation-timing-function: $easing; animation-iteration-count: infinite; }
.pp.is-demo .pp__chip--sans[data-row="head"] { animation-name: ppHcSans; }
.pp.is-demo .pp__chip--serif[data-row="head"] { animation-name: ppHcSerif; }
.pp.is-demo .pp__chip--mono[data-row="head"] { animation-name: ppHcMono; }
.pp.is-demo .pp__chip--wide[data-row="head"] { animation-name: ppHcWide; }
.pp.is-demo .pp__chip--sans[data-row="body"] { animation-name: ppBcSans; }
.pp.is-demo .pp__chip--serif[data-row="body"] { animation-name: ppBcSerif; }
.pp.is-demo .pp__chip--mono[data-row="body"] { animation-name: ppBcMono; }
.pp.is-demo .pp__chip--wide[data-row="body"] { animation-name: ppBcWide; }
@keyframes ppHead {
0%, 12.499% { font-family: var(--f-sans); font-style: normal; letter-spacing: 0; }
12.5%, 24.999% { font-family: var(--f-serif); font-style: normal; letter-spacing: 0; }
25%, 37.499% { font-family: var(--f-serif); font-style: normal; letter-spacing: 0; }
37.5%, 49.999% { font-family: var(--f-mono); font-style: italic; letter-spacing: 0; }
50%, 62.499% { font-family: var(--f-mono); font-style: italic; letter-spacing: .06em; }
62.5%, 74.999% { font-family: var(--f-sans); font-style: italic; letter-spacing: .06em; }
75%, 87.499% { font-family: var(--f-wide); font-style: normal; letter-spacing: 0; }
87.5%, 100% { font-family: var(--f-sans); font-style: normal; letter-spacing: 0; }
}
@keyframes ppBody {
0%, 24.999% { font-family: var(--f-sans); line-height: 1.5; }
25%, 37.499% { font-family: var(--f-wide); line-height: 1.5; }
37.5%, 49.999% { font-family: var(--f-serif); line-height: 1.5; }
50%, 74.999% { font-family: var(--f-mono); line-height: 1.9; }
75%, 87.499% { font-family: var(--f-sans); line-height: 1.7; }
87.5%, 100% { font-family: var(--f-sans); line-height: 1.5; }
}
// 제목 칩 넷: 켜지는 구간이 서로 다르다 (산 0·62.5·87.5 / 세리프 12.5 / 모노 37.5 / 넓은 75)
@keyframes ppHcSans {
0%, 12.499% { background-color: $color; color: $subject-cream; transform: none; }
12.5%, 62.499% { background-color: transparent; color: $subject-ink; transform: none; }
62.5% { background-color: $color; color: $subject-cream; transform: translateY(-2px); }
70%, 74.999% { background-color: $color; color: $subject-cream; transform: none; }
75%, 87.499% { background-color: transparent; color: $subject-ink; transform: none; }
87.5% { background-color: $color; color: $subject-cream; transform: translateY(-2px); }
95%, 100% { background-color: $color; color: $subject-cream; transform: none; }
}
@keyframes ppHcSerif {
0%, 12.499% { background-color: transparent; color: $subject-ink; transform: none; }
12.5% { background-color: $color; color: $subject-cream; transform: translateY(-2px); }
20%, 37.499% { background-color: $color; color: $subject-cream; transform: none; }
37.5%, 100% { background-color: transparent; color: $subject-ink; transform: none; }
}
@keyframes ppHcMono {
0%, 37.499% { background-color: transparent; color: $subject-ink; transform: none; }
37.5% { background-color: $color; color: $subject-cream; transform: translateY(-2px); }
45%, 62.499% { background-color: $color; color: $subject-cream; transform: none; }
62.5%, 100% { background-color: transparent; color: $subject-ink; transform: none; }
}
@keyframes ppHcWide {
0%, 74.999% { background-color: transparent; color: $subject-ink; transform: none; }
75% { background-color: $color; color: $subject-cream; transform: translateY(-2px); }
83%, 100% { background-color: $color; color: $subject-cream; transform: none; }
}
// 본문 칩 넷: 산 0·75 / 넓은 25 / 세리프 37.5 / 모노 50
@keyframes ppBcSans {
0%, 24.999% { background-color: $color; color: $subject-cream; transform: none; }
25%, 74.999% { background-color: transparent; color: $subject-ink; transform: none; }
75% { background-color: $color; color: $subject-cream; transform: translateY(-2px); }
83%, 100% { background-color: $color; color: $subject-cream; transform: none; }
}
@keyframes ppBcSerif {
0%, 37.499% { background-color: transparent; color: $subject-ink; transform: none; }
37.5% { background-color: $color; color: $subject-cream; transform: translateY(-2px); }
45%, 49.999% { background-color: $color; color: $subject-cream; transform: none; }
50%, 100% { background-color: transparent; color: $subject-ink; transform: none; }
}
@keyframes ppBcMono {
0%, 49.999% { background-color: transparent; color: $subject-ink; transform: none; }
50% { background-color: $color; color: $subject-cream; transform: translateY(-2px); }
58%, 74.999% { background-color: $color; color: $subject-cream; transform: none; }
75%, 100% { background-color: transparent; color: $subject-ink; transform: none; }
}
@keyframes ppBcWide {
0%, 24.999% { background-color: transparent; color: $subject-ink; transform: none; }
25% { background-color: $color; color: $subject-cream; transform: translateY(-2px); }
33%, 37.499% { background-color: $color; color: $subject-cream; transform: none; }
37.5%, 100% { background-color: transparent; color: $subject-ink; transform: none; }
}
// ── 좁은 화면: 침 견본(Aa)·둘째 줄·안내문을 치우고 칩과 견본을 줄인다 (값을 올리지 않고 내린다)
@media (max-width: 340px) {
.pp { gap: $sp-1; padding: $sp-2; }
.pp__row { gap: $sp-1; }
.pp__glabel { font-size: 10px; }
.pp__chip { padding: $sp-1; font-size: 10px; }
.pp__spec { display: none; }
.pp__knobs { gap: $sp-1; }
.pp__range { width: 48px; }
.pp__kval { font-size: 10px; }
.pp__sample { padding: $sp-1; }
.pp__head { font-size: 15px; }
.pp__body { font-size: 10px; }
.pp__b2, .pp__body br { display: none; }
.pp__note { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.pp.is-demo .pp__head,
.pp.is-demo .pp__body,
.pp.is-demo .pp__chip {
animation: none !important;
transform: none !important;
font-style: normal !important;
letter-spacing: 0 !important;
}
}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);
}
.pp {
--f-sans: system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
--f-serif: Georgia, "Times New Roman", ui-serif, serif;
--f-mono: ui-monospace, "Cascadia Mono", Consolas, "Courier New", monospace;
--f-wide: Verdana, "Segoe UI", "Noto Sans KR", sans-serif;
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 432px);
padding: 8px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.pp__row {
display: flex;
align-items: center;
gap: 8px;
}
.pp__glabel {
flex: 0 0 auto;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.pp__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1 1 auto;
min-width: 0;
}
.pp__chip {
flex: 0 0 auto;
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 999px;
background: transparent;
color: #17141a;
cursor: pointer;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
}
.pp__chip--sans {
font-family: var(--f-sans);
}
.pp__chip--serif {
font-family: var(--f-serif);
}
.pp__chip--mono {
font-family: var(--f-mono);
}
.pp__chip--wide {
font-family: var(--f-wide);
}
.pp__spec {
font-weight: 700;
font-size: 12px;
line-height: 1;
}
.pp__chip.is-on {
background: #17141a;
border-color: #17141a;
color: #fff7e6;
}
.pp__chip:focus-visible, .pp__ital:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pp__knobs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.pp__ital {
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 999px;
background: transparent;
color: #17141a;
cursor: pointer;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
}
.pp__ital.is-on {
background: #17141a;
border-color: #17141a;
color: #fff7e6;
}
.pp__knob {
display: inline-flex;
align-items: center;
gap: 4px;
color: #17141a;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
}
.pp__kval {
font-family: ui-monospace, "Cascadia Mono", Consolas, "Courier New", monospace;
color: #c93000;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.pp__range {
appearance: none;
-webkit-appearance: none;
width: 84px;
height: 16px;
background: transparent;
cursor: pointer;
}
.pp__range::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
}
.pp__range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
margin-top: -4px;
border: 0;
border-radius: 50%;
background: #0a4fe8;
}
.pp__range::-moz-range-track {
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
}
.pp__range::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: #0a4fe8;
}
.pp__sample {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pp__head {
margin: 0;
color: #17141a;
font-family: var(--f-sans);
font-weight: 800;
font-size: clamp(18px, 5vw, 24px);
line-height: 1.25;
letter-spacing: 0;
}
.pp__body {
margin: 0;
color: #17141a;
font-family: var(--f-sans);
font-weight: 400;
font-size: 13px;
line-height: 1.5;
}
.pp__note {
margin: 0;
color: rgba(23, 20, 26, 0.78);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
}
.pp.is-demo .pp__head {
animation-name: ppHead;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.pp.is-demo .pp__body {
animation-name: ppBody;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.pp.is-demo .pp__chip {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pp.is-demo .pp__chip--sans[data-row=head] {
animation-name: ppHcSans;
}
.pp.is-demo .pp__chip--serif[data-row=head] {
animation-name: ppHcSerif;
}
.pp.is-demo .pp__chip--mono[data-row=head] {
animation-name: ppHcMono;
}
.pp.is-demo .pp__chip--wide[data-row=head] {
animation-name: ppHcWide;
}
.pp.is-demo .pp__chip--sans[data-row=body] {
animation-name: ppBcSans;
}
.pp.is-demo .pp__chip--serif[data-row=body] {
animation-name: ppBcSerif;
}
.pp.is-demo .pp__chip--mono[data-row=body] {
animation-name: ppBcMono;
}
.pp.is-demo .pp__chip--wide[data-row=body] {
animation-name: ppBcWide;
}
@keyframes ppHead {
0%, 12.499% {
font-family: var(--f-sans);
font-style: normal;
letter-spacing: 0;
}
12.5%, 24.999% {
font-family: var(--f-serif);
font-style: normal;
letter-spacing: 0;
}
25%, 37.499% {
font-family: var(--f-serif);
font-style: normal;
letter-spacing: 0;
}
37.5%, 49.999% {
font-family: var(--f-mono);
font-style: italic;
letter-spacing: 0;
}
50%, 62.499% {
font-family: var(--f-mono);
font-style: italic;
letter-spacing: 0.06em;
}
62.5%, 74.999% {
font-family: var(--f-sans);
font-style: italic;
letter-spacing: 0.06em;
}
75%, 87.499% {
font-family: var(--f-wide);
font-style: normal;
letter-spacing: 0;
}
87.5%, 100% {
font-family: var(--f-sans);
font-style: normal;
letter-spacing: 0;
}
}
@keyframes ppBody {
0%, 24.999% {
font-family: var(--f-sans);
line-height: 1.5;
}
25%, 37.499% {
font-family: var(--f-wide);
line-height: 1.5;
}
37.5%, 49.999% {
font-family: var(--f-serif);
line-height: 1.5;
}
50%, 74.999% {
font-family: var(--f-mono);
line-height: 1.9;
}
75%, 87.499% {
font-family: var(--f-sans);
line-height: 1.7;
}
87.5%, 100% {
font-family: var(--f-sans);
line-height: 1.5;
}
}
@keyframes ppHcSans {
0%, 12.499% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
12.5%, 62.499% {
background-color: transparent;
color: #17141a;
transform: none;
}
62.5% {
background-color: #17141a;
color: #fff7e6;
transform: translateY(-2px);
}
70%, 74.999% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
75%, 87.499% {
background-color: transparent;
color: #17141a;
transform: none;
}
87.5% {
background-color: #17141a;
color: #fff7e6;
transform: translateY(-2px);
}
95%, 100% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
}
@keyframes ppHcSerif {
0%, 12.499% {
background-color: transparent;
color: #17141a;
transform: none;
}
12.5% {
background-color: #17141a;
color: #fff7e6;
transform: translateY(-2px);
}
20%, 37.499% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
37.5%, 100% {
background-color: transparent;
color: #17141a;
transform: none;
}
}
@keyframes ppHcMono {
0%, 37.499% {
background-color: transparent;
color: #17141a;
transform: none;
}
37.5% {
background-color: #17141a;
color: #fff7e6;
transform: translateY(-2px);
}
45%, 62.499% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
62.5%, 100% {
background-color: transparent;
color: #17141a;
transform: none;
}
}
@keyframes ppHcWide {
0%, 74.999% {
background-color: transparent;
color: #17141a;
transform: none;
}
75% {
background-color: #17141a;
color: #fff7e6;
transform: translateY(-2px);
}
83%, 100% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
}
@keyframes ppBcSans {
0%, 24.999% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
25%, 74.999% {
background-color: transparent;
color: #17141a;
transform: none;
}
75% {
background-color: #17141a;
color: #fff7e6;
transform: translateY(-2px);
}
83%, 100% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
}
@keyframes ppBcSerif {
0%, 37.499% {
background-color: transparent;
color: #17141a;
transform: none;
}
37.5% {
background-color: #17141a;
color: #fff7e6;
transform: translateY(-2px);
}
45%, 49.999% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
50%, 100% {
background-color: transparent;
color: #17141a;
transform: none;
}
}
@keyframes ppBcMono {
0%, 49.999% {
background-color: transparent;
color: #17141a;
transform: none;
}
50% {
background-color: #17141a;
color: #fff7e6;
transform: translateY(-2px);
}
58%, 74.999% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
75%, 100% {
background-color: transparent;
color: #17141a;
transform: none;
}
}
@keyframes ppBcWide {
0%, 24.999% {
background-color: transparent;
color: #17141a;
transform: none;
}
25% {
background-color: #17141a;
color: #fff7e6;
transform: translateY(-2px);
}
33%, 37.499% {
background-color: #17141a;
color: #fff7e6;
transform: none;
}
37.5%, 100% {
background-color: transparent;
color: #17141a;
transform: none;
}
}
@media (max-width: 340px) {
.pp {
gap: 4px;
padding: 8px;
}
.pp__row {
gap: 4px;
}
.pp__glabel {
font-size: 10px;
}
.pp__chip {
padding: 4px;
font-size: 10px;
}
.pp__spec {
display: none;
}
.pp__knobs {
gap: 4px;
}
.pp__range {
width: 48px;
}
.pp__kval {
font-size: 10px;
}
.pp__sample {
padding: 4px;
}
.pp__head {
font-size: 15px;
}
.pp__body {
font-size: 10px;
}
.pp__b2, .pp__body br {
display: none;
}
.pp__note {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.pp.is-demo .pp__head,
.pp.is-demo .pp__body,
.pp.is-demo .pp__chip {
animation: none !important;
transform: none !important;
font-style: normal !important;
letter-spacing: 0 !important;
}
}var pp = document.getElementById('pp');
var head = document.getElementById('head');
var body = document.getElementById('body');
var ital = document.getElementById('ital');
var ls = document.getElementById('ls');
var lh = document.getElementById('lh');
var lsVal = document.getElementById('lsVal');
var lhVal = document.getElementById('lhVal');
// 칩은 제 저마다 해당 서체로 그 이름을 쓰고 있다 — 컴퓨티드 font-family 를 그대로 견본에 옮긴다
function stack(btn) { return getComputedStyle(btn).fontFamily; }
function pick(btn) {
pp.classList.remove('is-demo');
var target = btn.getAttribute('data-row') === 'head' ? head : body;
target.style.fontFamily = stack(btn);
var row = btn.parentNode;
var chips = row.querySelectorAll('.pp__chip');
for (var i = 0; i < chips.length; i++) {
var on = chips[i] === btn;
chips[i].classList.toggle('is-on', on);
chips[i].setAttribute('aria-pressed', on ? 'true' : 'false');
}
}
var allChips = document.querySelectorAll('.pp__chip');
for (var i = 0; i < allChips.length; i++) {
allChips[i].addEventListener('click', function () { pick(this); });
}
ital.addEventListener('click', function () {
pp.classList.remove('is-demo');
var on = ital.getAttribute('aria-pressed') !== 'true';
ital.setAttribute('aria-pressed', on ? 'true' : 'false');
ital.classList.toggle('is-on', on);
head.style.fontStyle = on ? 'italic' : 'normal';
});
ls.addEventListener('input', function () {
pp.classList.remove('is-demo');
head.style.letterSpacing = ls.value + 'em';
lsVal.textContent = Number(ls.value).toFixed(2) + 'em';
});
lh.addEventListener('input', function () {
pp.classList.remove('is-demo');
body.style.lineHeight = lh.value;
lhVal.textContent = lh.value;
});// FontPairingPreview — vanilla 판(run/074/demo/07-font-pairing-preview/index.html) 의 React 포트.
// 두 칩줄(제목·본문)이 각자 font-family 를 고르고 기울임·자간·행간 손잡이가 한 패널에 붙는다.
// 칩의 컴퓨티드 font-family 를 견본에 옮기는 방식도 vanilla 와 같다 — 서체 스택을 두 번 쓰지 않는다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FontPairingPreview.module.scss";
export type FontPairingPreviewProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ClickEvt = { currentTarget: HTMLButtonElement };
const FAMILIES = ["sans", "serif", "mono", "wide"] as const;
type Family = (typeof FAMILIES)[number];
const FAMILY_LABEL: Record<Family, string> = { sans: "산세리프", serif: "세리프", mono: "모노", wide: "넓은산스" };
const ROW_LABEL: Record<"head" | "body", string> = { head: "제목", body: "본문" };
const HEAD_SAMPLE = "봄바람 소식";
const BODY_SAMPLE = "제목과 본문이 한 목소리를 내면 페이지가 조용해집니다.";
const BODY_SAMPLE2 = "쌍이 어울리는지는 세 줄만 읽어 보면 압니다.";
const NOTE = "두 칩줄에서 각자 골라 조합을 바로 겁니다";
const ITALIC_LABEL = "기울임";
const TRACKING_LABEL = "자간";
const LEADING_LABEL = "행간";
const HEAD_ARIA = "제목 서체 고르기";
const BODY_ARIA = "본문 서체 고르기";
const TRACKING_ARIA = "제목 자간";
const LEADING_ARIA = "본문 행간";
const SPEC = "Aa";
export default function FontPairingPreview(props: FontPairingPreviewProps) {
const duration = props.duration ?? 220;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#17141a";
const [preview, setPreview] = useState<boolean>(true);
const [headFamily, setHeadFamily] = useState<Family>("sans");
const [bodyFamily, setBodyFamily] = useState<Family>("sans");
const [italic, setItalic] = useState<boolean>(false);
const [tracking, setTracking] = useState<number>(0);
const [leading, setLeading] = useState<number>(1.5);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const chip = (row: "head" | "body", f: Family) => {
const on = (row === "head" ? headFamily : bodyFamily) === f;
const cls = [
styles.chip,
styles["chip--" + f],
on ? styles.ison : "",
preview ? "" : "",
].filter(Boolean).join(" ");
return (
<button
key={row + f}
className={cls}
type="button"
data-row={row}
aria-pressed={on ? "true" : "false"}
onClick={(e: ClickEvt) => {
setPreview(false);
if (row === "head") setHeadFamily(f);
else setBodyFamily(f);
}}
>
<span className={styles.spec} aria-hidden="true">{SPEC}</span>
{FAMILY_LABEL[f]}
</button>
);
};
return (
<main className={styles.stage}>
<div className={styles.root + (preview ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.row}>
<span className={styles.glabel}>{ROW_LABEL.head}</span>
<div className={styles.chips} role="group" aria-label={HEAD_ARIA}>
{FAMILIES.map((f) => chip("head", f))}
</div>
</div>
<div className={styles.row}>
<span className={styles.glabel}>{ROW_LABEL.body}</span>
<div className={styles.chips} role="group" aria-label={BODY_ARIA}>
{FAMILIES.map((f) => chip("body", f))}
</div>
</div>
<div className={styles.knobs}>
<button
className={styles.ital + (italic ? " " + styles.ison : "")}
type="button"
aria-pressed={italic ? "true" : "false"}
onClick={() => { setPreview(false); setItalic(!italic); }}
>
{ITALIC_LABEL}
</button>
<label className={styles.knob}>
{TRACKING_LABEL}
<input
className={styles.range}
type="range" min="-0.02" max="0.12" step="0.01" value={tracking}
aria-label={TRACKING_ARIA}
onChange={(e: { target: { value: string } }) => { setPreview(false); setTracking(Number(e.target.value)); }}
/>
<output className={styles.kval}>{tracking.toFixed(2) + "em"}</output>
</label>
<label className={styles.knob}>
{LEADING_LABEL}
<input
className={styles.range}
type="range" min="1.2" max="2" step="0.1" value={leading}
aria-label={LEADING_ARIA}
onChange={(e: { target: { value: string } }) => { setPreview(false); setLeading(Number(e.target.value)); }}
/>
<output className={styles.kval}>{String(leading)}</output>
</label>
</div>
<div className={styles.sample}>
<h2
className={styles.head + " " + styles["f-" + headFamily]}
style={{ fontStyle: italic ? "italic" : "normal", letterSpacing: tracking + "em" }}
>
{HEAD_SAMPLE}
</h2>
<p className={styles.body + " " + styles["f-" + bodyFamily]} style={{ lineHeight: String(leading) }}>
<span className={styles.b1}>{BODY_SAMPLE}</span>
<br />
<span className={styles.b2}>{BODY_SAMPLE2}</span>
</p>
</div>
<p className={styles.note}>{NOTE}</p>
</div>
</main>
);
}07 見出し・本文ペアプレビュー — 計測 累積面積 26.822% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · css タイポグラフィ プレイグラウンド 9種 — webフォントを読み込まずに
このデモが属する記事
css タイポグラフィ プレイグラウンド 9種 — webフォントを読み込まずにcss タイポグラフィとは、フォントのダウンロードを待たずにサイズ・太さ・字間・行間でページの読み心地を決める作業です。この9つのプレイグラウンドは webフォントを読み込まずに、OS がもともと持っているシステムフォントだけで組んであり、 この記事のデモをすべて見る


