🧩 Design System3. Typography & Spacing06 — Responsive Type & Fluid Scale

06 — Responsive Type & Fluid Scale

이 문서가 답하는 질문: “모바일에서는 14px, 데스크톱에서는 18px”을 더 이상 미디어 쿼리 5개로 표현하지 않는다. 그럼 어떻게? 그리고 그 방식이 사용자의 브라우저 줌과 충돌하지 않으려면? 한 줄 답 (Pyramid Top): clamp(min, preferred, max) 한 줄이 연속적 fluid type을 만든다 — 그리고 그 비례 기준을 vw(viewport) 가 아니라 cqi(container inline) 로 옮기면 같은 컴포넌트가 어느 컨테이너에 들어가도 그 폭에 맞춰 자동 스케일된다. 핵심은 모든 단위는 rempx로 잠그면 사용자 폰트 크기 설정이 무력화된다.


Why — 미디어 쿼리 폭발 + 접근성 줌

1) 전통 미디어 쿼리의 한계

/* 5개의 불연속 점프 */
h1 { font-size: 24px; }
@media (min-width: 480px)  { h1 { font-size: 28px; } }
@media (min-width: 768px)  { h1 { font-size: 36px; } }
@media (min-width: 1024px) { h1 { font-size: 44px; } }
@media (min-width: 1440px) { h1 { font-size: 56px; } }
  • 481px에서 갑자기 28→36 점프. 사이 사이즈는 불가.
  • 같은 컴포넌트가 어느 콘텐츠 컬럼에 들어갔는지를 모름 — sidebar(300px)와 main(900px) 둘 다 viewport 1024px에서는 같은 사이즈.
  • 폰트 사이즈마다 이걸 반복 → CSS가 불어남.

2) px 고정의 접근성 위반

.body { font-size: 14px; }     /* ❌ */
  • 사용자가 브라우저 폰트 크기를 크게 설정해도 안 바뀜.
  • WCAG 2.2 의 resize 기준 (200%까지 확대 가능해야 함) 위반.
  • 노안·저시력 사용자 차단.
풀려는 문제미디어쿼리clamp()container query
사이 사이즈불가연속연속
컨테이너 인식불가불가 (viewport만)가능
미디어쿼리 수4~6개0개0개
접근성 줌px면 깨짐rem 사용 시 OKrem 사용 시 OK

How — clamp() 3-인자 공식

font-size: clamp(MIN, PREFERRED, MAX);
  • MIN: 절대 이 아래로 안 내려감.
  • PREFERRED: 이상값 (보통 vw 또는 cqi 기반 동적 값).
  • MAX: 절대 이 위로 안 올라감.

기본 패턴 (viewport 기반)

:root {
  font-size: clamp(1rem, 0.5rem + 1vw, 1.25rem);
}
  • 320px 화면: 0.5rem + 1vw = 8 + 3.2 = 11.2px → MIN 1rem (16px) 적용.
  • 1280px: 0.5rem + 1vw = 8 + 12.8 = 20.8px → MAX 1.25rem (20px) 적용.
  • 사이: 연속 보간.

공식 유도

원하는 결과: 320px → 16px, 1280px → 20px.

font-size = a × vw + b × rem

at vw=320: 320a + 16b = 16
at vw=1280: 1280a + 16b = 20

→ 960a = 4
→ a = 0.00417 = 0.417vw
→ b × 16 = 16 - 320 × 0.00417 = 14.67
→ b = 0.917rem

font-size: clamp(1rem, 0.917rem + 0.417vw, 1.25rem)

더 단순한 자동 계산 도구

  • utopia.fyi — viewport 범위 + 스케일 ratio 입력 → 토큰 자동 생성.
  • fluid.tw — Tailwind 클래스 자동.

What — Container query 기반 fluid (cqi)

2023년 표준화. 부모 컨테이너의 폭을 기준으로 스케일.

.card {
  container-type: inline-size;   /* 이 element가 *컨테이너* 가 됨 */
}
 
.card-title {
  font-size: clamp(1rem, 0.5rem + 4cqi, 2rem);
  /* cqi = container's inline size의 1% */
}

동작

  • card가 300px 폭: 0.5rem + 4cqi = 8 + 12 = 20px → 토큰 사이.
  • 같은 card가 800px 폭: 0.5rem + 4cqi = 8 + 32 = 40px → MAX 2rem (32px).

viewport vs container 비교

기준vwcqi
비례 단위viewport 1%container 1%
같은 컴포넌트, 다른 위치같은 크기다른 크기 (컨테이너별)
적합페이지 전체 헤더재사용 가능한 카드·컴포넌트
브라우저 지원100%+Chrome 105+ / Safari 16+ / Firefox 110+

What — Fluid type token 시스템

Utopia-style fluid scale

:root {
  /* viewport 320~1240 사이에서 fluid */
  --step--2: clamp(0.6944rem, 0.6597rem + 0.1736vw, 0.8rem);   /* ~11~13px */
  --step--1: clamp(0.8333rem, 0.7857rem + 0.2381vw, 0.9722rem);
  --step-0:  clamp(1rem,     0.9348rem + 0.3261vw, 1.2153rem);  /* base */
  --step-1:  clamp(1.2rem,   1.1115rem + 0.4423vw, 1.5191rem);
  --step-2:  clamp(1.44rem,  1.3211rem + 0.5946vw, 1.8989rem);
  --step-3:  clamp(1.728rem, 1.5694rem + 0.7929vw, 2.3737rem);
  --step-4:  clamp(2.0736rem,1.8623rem + 1.0566vw, 2.9671rem);
  --step-5:  clamp(2.4883rem,2.2074rem + 1.4042vw, 3.7089rem);
}
 
h1 { font-size: var(--step-5); }
h2 { font-size: var(--step-4); }
p  { font-size: var(--step-0); }

Panda CSS 적용

theme: {
  tokens: {
    fontSizes: {
      // static
      sm:   { value: "0.875rem" },
      base: { value: "1rem"     },
      lg:   { value: "1.25rem"  },
 
      // fluid
      "fluid.sm":   { value: "clamp(0.875rem, 0.85rem + 0.125vw, 1rem)" },
      "fluid.base": { value: "clamp(1rem, 0.95rem + 0.25vw, 1.125rem)" },
      "fluid.lg":   { value: "clamp(1.125rem, 1.05rem + 0.375vw, 1.375rem)" },
      "fluid.xl":   { value: "clamp(1.25rem, 1.1rem + 0.75vw, 1.875rem)" },
      "fluid.2xl":  { value: "clamp(1.5rem, 1.25rem + 1.25vw, 2.5rem)" },
      "fluid.3xl":  { value: "clamp(2rem, 1.5rem + 2.5vw, 3.75rem)" }
    }
  }
}

fluid는 hero·display에만. 본문은 static rem이 가독성에 좋다 (점진적 변동이 읽기에 산만).


What — 접근성 (A11y) — rem 우선

rem의 의미

  • 1rem = root element의 font-size.
  • 사용자가 브라우저 설정에서 기본 폰트 크기를 18px로 바꾸면 1rem = 18px이 됨.
  • px고정 — 사용자 설정 무시.

잘못된 패턴

/* ❌ */
html { font-size: 16px; }   /* 절대 px 지정 — 사용자 설정 덮어씀 */
.body { font-size: 14px; }

올바른 패턴

/* ✅ */
html {
  /* font-size 지정 안 함 → browser default (보통 16px) */
}
.body {
  font-size: 0.875rem;   /* 14px @ 16px base. 사용자가 20px base면 17.5px */
}

clamp + rem + vw 결합 — 접근성 안전

/* WCAG 안전한 fluid */
h1 {
  font-size: clamp(1.5rem, 1rem + 2vw, 3rem);
  /* 모든 인자가 rem 또는 viewport 단위 → 사용자 줌 시 *모두 스케일* */
}
 
/* ❌ px이 섞이면 위험 */
h1 {
  font-size: clamp(24px, 16px + 2vw, 48px);   /* 사용자 줌 무시 */
}

Tailwind v4의 단위 정책

Tailwind v4부터 기본 단위가 rem. text-base = 1rem. 이게 접근성 best practice 와 일치.


What — Tailwind v4 fluid + container query

{/* container query */}
<div class="@container">
  <h1 class="@sm:text-2xl @md:text-3xl @lg:text-5xl">
    제목
  </h1>
</div>
  • @container = container-type: inline-size.
  • @sm:, @md:, @lg:: 컨테이너의 내부 폭 기준 (viewport 아님).

임의 clamp

<h1 class="text-[clamp(1.5rem,_1rem_+_2vw,_3rem)]">
  Hero
</h1>

또는 토큰화:

@theme {
  --text-fluid-hero: clamp(1.5rem, 1rem + 2vw, 3rem);
}
<h1 class="text-fluid-hero">Hero</h1>

What-if — 잘못 쓰면 어떻게 깨지는가

  • 함정 1: px로 clamp 작성clamp(16px, 1vw + 8px, 24px). 사용자 200% 줌글자가 그대로. 대응: 모든 인자를 *rem 또는 em*으로.
  • 함정 2: fluid를 본문에 적용 — 본문 폰트가 스크롤·resize 시 미세하게 변동하면 읽기가 산만. 대응: fluid는 display/hero/heading만.
  • 함정 3: 너무 가파른 fluid 기울기clamp(1rem, 5vw, 4rem) — viewport 5%면 모바일→데스크톱 사이 4배. 모바일에선 너무 작고, 데스크톱에선 너무 큼. 대응: 0.5~1.5vw 범위 권장.
  • 함정 4: container query 부모 누락cqi 사용했는데 부모에 container-type 없음 → 0으로 계산. 대응: 항상 컨테이너 명시.
  • 함정 5: cqi + nested container — 안쪽 컨테이너의 cqi어느 부모 기준인지 헷갈림. 대응: container-name 으로 명시 (container-name: card, 사용 시 container: card / inline-size).
  • 함정 6: 사용자가 prefers-reduced-motion인데 fluid가 resize 시 transition애니메이션 멀미 유발 가능. 대응: clamp 자체는 transition 없는 즉시 적용이라 보통 안전. 다만 width animation은 따로 가드.

Insight — cqi가 진짜 게임 체인저인 이유

clamp() + vw 패턴은 2020년경 Mike RiethmullerUtopia가 대중화했다. 하지만 2년 뒤에 등장한 cqi가 진짜 패러다임을 바꿨다.

디자인 시스템에서의 충격

이전: 같은 Card 컴포넌트가 sidebar(300px) 에 들어가도, main column(900px) 에 들어가도 같은 헤딩 크기. 디자이너는 두 가지 variant(Card.compact, Card.spacious)를 만들어 동시에 관리해야 했음.

이후: cqi 한 줄로 card 자체가 자기 컨테이너 폭을 알고 스케일. variant가 불필요해짐.

진짜 재사용 가능한 컴포넌트가 가능해짐

/* 컴포넌트 1개. 어떤 컨테이너에 넣어도 자동 적응 */
.card {
  container-type: inline-size;
}
.card-title {
  font-size: clamp(1rem, 4cqi, 2rem);
}
.card-padding {
  padding: clamp(0.5rem, 3cqi, 2rem);
}

→ 디자인 시스템 컴포넌트의 “한 번 만들면 어디서나 동작” 약속을 비로소 실현. 이게 2024년 디자인 시스템 트렌드Component variants 줄이기 + container-aware로 옮겨가는 이유.

한계

container query는 그 element가 layout flow 안의 부모를 보지만, grid/flex의 형제 widths를 보지 못함. 즉 truly content-aware layout(글자 길이에 따라 사이즈)은 아직 style query(2025년 표준화 중)나 JS measurement가 필요.


요약

  • 미디어쿼리 대신 clamp(min, preferred, max) 한 줄로 연속적 fluid type.
  • 비례 단위: vw(viewport) → cqi(container) 로 옮기는 게 재사용 가능한 컴포넌트의 핵심.
  • 모든 단위는 rempx는 접근성 줌을 차단.
  • fluid는 hero/heading 전용. 본문은 static rem.
  • container-type: inline-size를 부모에 명시해야 cqi 동작.