지적받은 것: 모바일 마진·패딩, 폰트 웨이트, 버튼 스타일 불일치, SSOT,
스크롤바, 모바일 메뉴, 그리고 3D 를 왜 상자에 넣었는지.
3D 의 역할을 잘못 잡았다
lusion.co 의 "3D 는 액자 안"은 3D 자체가 상품일 때의 규칙이다.
우리는 절차를 파는데 액자에 넣을 상품이 없어 "저 상자는 뭐냐"가 됐고,
의미를 게이트 판독기가 자막으로 설명하고 있었다.
이 씬의 역할은 재질이다. 배경으로 되돌리고 값을 전부 낮췄다 —
테두리 광 0.85->0.42, 알파 0.72->0.34, UV 경계 0.05->0.17, 이동 60%
ResizeObserver 로 캔버스를 따라가게 했다. window.resize 만 듣는 동안
히어로 높이 변화를 놓쳐 배경이 화면의 3분의 2만 덮었다
SSOT
웨이트 6종(400/500/560/600/620/660) -> 3종. 셋은 토큰에 없던 값이었다
.btn + btn-solid/btn-quiet. 두 버튼이 높이 36 vs 44, 패딩 16 vs 8,
테두리 1px vs 0, 웨이트 400 vs 500 이었다
--header-h. 간격을 clamp 로 바꾸자 관계가 끊겨 h1 이 헤더 뒤로 들어갔다
--space-5~8 과 --gutter 를 clamp 로. 고정이면 모바일 여백이 화면의 26%
scrollbar-color 지정
모바일
좌우 여백 24 -> 32px, 섹션 상하 112 -> 56px
헤더에 섹션 앵커 4개. 900px 미만은 details 기반 메뉴(JS 0바이트)
패럴랙스는 섹션마다 다른 말을 한다
히어로 글자 상승 / 배경 하강 — 반대 방향이라야 시차가 보인다
파이프라인 단계명 고정, 산출물만 지연 — 인과
재료 타일이 서로 다른 깊이 — 표면은 한 겹이 아니다
프리플라이트 뒤 항목일수록 늦게 — 로그는 흐른다
수치 없음. 의도적 정지 — 계기판의 바늘은 떨지 않는다
스킬
three.md §0-A 를 역할별로 갈랐다. "액자 안"만 적어둔 것이 잘못된
일반화였고 내가 거기 그대로 걸렸다. 재질일 때의 값 교정을 실측으로 남김
tokens.md 웨이트·컨트롤·헤더 높이·반응형 간격 — 토큰이 아예 없던 네 축
motion.md 섹션별 패럴랙스 배정표. 움직이지 않는 것도 결정이다
390px 실렌더: 가로 스크롤 0, 대비 실패 0, 웨이트 3종, radius 2종. 테스트 22개 통과
13 KiB
tokens — 디자인 토큰과 성능 예산
3단계에서 읽는다. 코드를 쓰기 전에 숫자를 정한다. 구현하면서 색을 고르면 매번 다른 색이 나온다.
토큰은 취향이 아니라 계약이다. 한번 정하면 이후 모든 값은 여기서 나온다. 하드코딩된 값이 하나라도 있으면 수정 요청 한 번에 무너진다.
1. 타입 스케일
비율을 먼저 고른다
| 비율 | 값 | 인상 | 언제 |
|---|---|---|---|
| Minor Third | 1.200 | 차분, 조밀 | 정보 밀도가 높은 페이지, 문서, 대시보드 |
| Major Third | 1.250 | 안정 | 기본값. 대부분의 마케팅 사이트 |
| Perfect Fourth | 1.333 | 또렷한 위계 | 랜딩 페이지, 제품 소개 |
| Golden | 1.618 | 극적 | 포트폴리오, 에디토리얼. 중간 단계가 비어 본문이 외로워진다 |
한 페이지에 비율은 하나다. 헤드라인만 다른 비율을 쓰고 싶으면 그건 비율이 아니라 디스플레이 사이즈를 따로 정의하는 것이다.
실제 값으로 적는다
:root {
/* Perfect Fourth (1.333), 본문 16px 기준 */
--step--1: 0.75rem; /* 12 — 캡션, 레이블 */
--step-0: 1rem; /* 16 — 본문 */
--step-1: 1.333rem; /* 21 — 리드 문단, 소제목 */
--step-2: 1.777rem; /* 28 — h3 */
--step-3: 2.369rem; /* 38 — h2 */
--step-4: 3.157rem; /* 51 — h1 */
--step-5: 4.209rem; /* 67 — 디스플레이 */
}
반응형은 clamp 로, 미디어쿼리로 하지 마라
--step-4: clamp(2.25rem, 1.5rem + 3.75vw, 3.157rem);
clamp(최소, 기준+vw, 최대). 최소값은 모바일에서 읽히는 크기, 최대값은 데스크톱 기준. 중간이 매끄럽게 이어져 중간 뷰포트에서 깨지지 않는다.
함께 정해야 하는 것
| 토큰 | 규칙 |
|---|---|
--leading-tight |
1.1~1.2 — 디스플레이/헤드라인 |
--leading-normal |
1.5 |
--measure |
한 줄 길이. 라틴 60 |
| 자간 | 큰 글자에만 음수(-0.02em 정도). 한글에는 음수 자간 금지 |
폰트는 최대 2종(+ 코드용 모노 1). 세 번째 텍스트 폰트를 넣고 싶다면 위계를 웨이트로 못 만들고 있다는 신호다.
어떤 폰트를 어떻게 고르고 싣는지는
typography.md가 정본이다. 여기서는 스케일 값만 정한다. 로딩 전략, 폴백 메트릭 보정, OpenType 기능, 한글 서브셋, 라이선스 확인이 그 문서에 있다.
2. 색
역할로 정의한다. 팔레트로 정의하지 마라
"파랑 5단계"가 아니라 무엇에 쓰이는 색인지로 정의한다.
:root {
--surface: /* 페이지 바탕 */
--surface-raised: /* 카드·패널 (바탕과 구분되되 튀지 않게) */
--ink: /* 본문 텍스트 */
--ink-muted: /* 보조 텍스트 — 대비 4.5:1 유지 */
--line: /* 경계선 */
--accent: /* 강조 — 페이지당 하나 */
--accent-ink: /* 강조 위에 올라가는 글자색 */
}
규칙
- 강조색은 하나다. 두 개가 필요하다고 느끼면 위계 설계가 실패한 것이다. 예외: 상태색(성공/경고/오류)은 강조색이 아니라 기능색이다
- 채도가 높은 색은 면적을 좁게. 넓은 면적에 쓰면 눈이 피로하고 싸구려로 보인다
- 중성색도 색이다. 순수 회색(
#808080) 대신 강조색 쪽으로 약간 기운 중성색을 쓰면 화면 전체가 하나로 묶인다 - 대비를 측정해라. 본문 4.5:1, 큰 글자 3:1. 눈으로 판단하지 마라
다크 모드
다크가 슬롭인 게 아니라 고르지 않은 다크가 슬롭이다. (antipatterns.md §1 — bun.sh 는 다크인데도 슬롭 항목을 거의 전부 회피한다)
다크를 기본으로 하려면 근거를 한 줄로 대라. 정당화되는 이유의 목록은 presets/dark-instrument.md 에 있다(야간 운영 환경, 밝은 데이터 시각화의 대비, 제품 자체가 어두움). 댈 수 없으면 라이트로 간다.
어느 쪽을 기본으로 하든 두 테마를 동등하게 정의한다. 다크만 만들고 라이트를 빼는 것은 사용자 선택권을 뺏는 것이다.
지원할 때는 색을 뒤집는 게 아니라 역할별로 다시 정의한다. 다크에서 순수 검정(#000)은 대비가 너무 세서 눈이 아프고, 순수 흰색 텍스트도 마찬가지다.
:root { --surface: #fbfaf8; --ink: #1a1917; }
@media (prefers-color-scheme: dark) {
:root { --surface: #14130f; --ink: #e8e4dc; }
}
3. 간격
하나의 리듬에서 파생시킨다
:root {
--space-1: 0.25rem; /* 4 */
--space-2: 0.5rem; /* 8 */
--space-3: 1rem; /* 16 */
--space-4: 1.5rem; /* 24 */
--space-5: 2.5rem; /* 40 */
--space-6: 4rem; /* 64 */
--space-7: 6rem; /* 96 */
--space-8: 10rem; /* 160 — 섹션 간격 */
}
중간 값을 즉석에서 만들지 마라. --space-4 와 --space-5 사이가 필요하다면 스케일이 잘못된 것이다.
여백이 위계를 만든다
- 관련 있는 것끼리는 가깝게, 다른 그룹과는 확실히 멀게. 애매한 중간 간격이 가장 나쁘다
- 섹션 간격은 본문 간격의 4배 이상. 좁으면 페이지가 뭉개진다
- 요소를 정렬할 때 간격이 아니라 정렬선을 먼저 맞춰라
3-b. 형태 (radius·선)
하드 게이트 #3 이 검사하는 대상이다. 정의하지 않으면 검사할 수 없다.
:root {
--radius-sm: 2px; /* 입력, 배지 */
--radius-md: 8px; /* 카드, 패널 */
--radius-pill: 999px;
--line-width: 1px;
}
서로 다른 radius 값은 3종 미만으로. 어휘가 많을수록 우연히 결정된 것으로 보인다.
1종만 쓰는 것도 정당한 선택이다 — swiss-minimal·anti-grid 에서는 오히려 그쪽이 맞다.
4. 모션 토큰
:root {
--dur-instant: 100ms; /* 상태 변화 — 호버, 포커스 */
--dur-quick: 200ms; /* 작은 요소 등장/퇴장 */
--dur-normal: 350ms; /* 패널, 모달 */
--dur-slow: 600ms; /* 페이지 전환, 큰 이동 */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* 들어오는 것 — 기본값 */
--ease-in: cubic-bezier(0.64, 0, 0.78, 0); /* 나가는 것 */
--ease-soft: cubic-bezier(0.4, 0, 0.2, 1); /* 위치 이동 */
}
상세는 references/motion.md. 여기서는 값을 고정하는 것이 목적이다. 컴포넌트마다 다른 duration 을 쓰면 페이지가 불안해 보인다.
웨이트와 컨트롤 — 자주 빠지는 두 축
타입 스케일·색·간격은 대부분 토큰으로 잡는다. 웨이트와 컨트롤 치수는 거의 안 잡는다. 그래서 SSOT 가 거기서 먼저 깨진다.
웨이트는 세 개다
--weight-body: 400;
--weight-medium: 500;
--weight-strong: 600;
컴포넌트마다 font-weight: 560 같은 값을 직접 쓰기 시작하면 페이지에 웨이트가 여섯 종이 된다.
실측 사례: designpaca 소개 페이지가 400 / 500 / 560 / 600 / 620 / 660 을 동시에 쓰고 있었고,
그중 셋은 토큰에 없는 값이었다.
가변폰트는 660 같은 중간값도 그려준다. 그래서 더 위험하다 — 표준 축(400/500/600/700)을 벗어나면 힌팅이 흐려지고, 폴백 폰트에서는 아예 다른 굵기로 떨어진다.
웨이트로 위계를 만들려 들지 마라. R1 실측(zed.dev 모바일)에서 font-weight: 400 이 832회,
나머지 전부 합쳐 12회였다. 위계는 크기와 색이 만든다.
버튼처럼 생긴 것은 전부 같은 치수를 쓴다
--control-h: 2.75rem; /* 44px — 터치 타깃 */
--control-h-sm: 2.25rem; /* 36px — 헤더 등 조밀한 자리 */
--control-pad-x: var(--space-3);
컴포넌트마다 높이와 패딩을 다시 정하면 그때부터 SSOT 가 아니다.
실측 사례: 같은 페이지의 두 버튼이 높이 36 vs 44, 패딩 16 vs 8, 테두리 1px vs 0,
웨이트 400 vs 500 이었다. 규칙이 없으니 전부 달랐다.
반대로 R1 의 두 버튼은 h36 · pad-x10 · r4 · w400 · 14px 로 픽셀 단위까지 같았다.
떠 있는 헤더는 높이를 토큰으로 내놔야 한다
--header-h: calc(var(--space-3) + var(--space-2) * 2 + var(--control-h-sm));
position: fixed 헤더는 문서 흐름에서 빠져 있어서, 첫 섹션의 상단 여백과 앵커 목적지
(scroll-margin-top)가 그 높이를 알아야 한다. 값을 각자 손으로 맞춰두면
간격 토큰을 clamp() 로 바꾸는 순간 관계가 끊어져 제목이 헤더 뒤로 들어간다.
값이 아니라 관계를 토큰으로 둬라.
큰 간격은 화면에 반응해야 한다
작은 값(4~24px)은 고정, 큰 값만 clamp().
--space-6: clamp(2.5rem, 1.6rem + 2.6vw, 4rem);
--space-7: clamp(3.5rem, 1.9rem + 4.6vw, 7rem);
--gutter: clamp(2rem, 1.4rem + 1.8vw, 2.5rem);
고정으로 두면 모바일에서 섹션 상하 여백 112px 이 그대로 들어가 화면 높이의 26% 를 여백이 먹는다. 좌우 여백도 마찬가지다 — R1 모바일 실측은 32px 이었다.
5. 성능 예산
여기서 정한다. 구현 후에 재면 이미 늦었다.
이 표가 성능 예산의 유일한 원본이다. SKILL.md 도 preflight.md 도 여기를 가리킨다. 다른 문서에 예산 표를 만들면 값이 갈라지고, 갈라진 순간 아무도 어느 쪽이 맞는지 모른다.
| 항목 | 기본 | 데모/포트폴리오 | 5단계에서 |
|---|---|---|---|
| 히어로까지 JS (gzip) | 150KB | 400KB | 측정 |
| WebGL/3D 추가분 | +200KB | +600KB | 측정 |
| 총 전송량 (첫 화면) | 1MB | 2MB | 측정 |
| 첫 인터랙션 (모바일 4G) | 3초 | 5초 | 측정 |
| LCP | 2.5초 | 3.5초 | 측정 |
| CLS | 0.1 | 0.1 | 측정 |
| 폰트 패밀리 | 2개 이하 | 3개 | 개수 확인 |
| 폰트 전송량(첫 화면) | 100KB | 200KB | 측정 |
| 애니메이션 속성 | transform/opacity 만 | 동일 (타협 없음) | grep |
예산을 올렸다면 올렸다는 사실과 이유를 명시해라. 조용히 넘기는 것이 가장 나쁘다.
폰트는 파일 개수가 아니라 패밀리 수와 전송량으로 센다. 한글 웹폰트를 유니코드 범위별로 수십 개 파일로 쪼개는 것은 올바른 최적화다(Pretendard
dynamic-subset은 14개 이상). 브라우저는 페이지에 실제로 쓰인 글자 범위만 받는다. 파일 개수를 줄이라고 요구하면 한글 프로젝트를 단일 대용량 파일이라는 잘못된 방향으로 몬다.
LCP·CLS 를 어떻게 재나
preflight.md 가 이 값을 요구한다. 재는 법이 없으면 "미측정"으로 남고, 미측정은 통과가 아니다.
<!-- 페이지에 임시로 넣고 콘솔을 본다. 측정 후 반드시 제거한다 -->
<script>
new PerformanceObserver((l) => {
const e = l.getEntries().at(-1);
console.log('LCP', Math.round(e.startTime), e.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
let cls = 0;
new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (!e.hadRecentInput) cls += e.value;
console.log('CLS', cls.toFixed(3));
}).observe({ type: 'layout-shift', buffered: true });
</script>
프로덕션 빌드에 돌려라. 개발 서버는 번들이 다르고 HMR 스크립트가 섞여 값이 의미 없다. Lighthouse 를 쓸 수 있으면 그쪽이 더 정확하다 — 단 모바일 프로파일로.
예산을 지키는 기본 수단
- 폰트: 상세는
typography.md§3~§4. 요약하면font-display를 의식적으로 고르고, preload 는 첫 화면에 실제로 쓰는 파일만, 폴백 메트릭을 보정하거나optional을 쓴다 - 이미지: 실제 표시 크기의 2배까지만, AVIF/WebP, 첫 화면 밖은
loading="lazy" - JS: 첫 화면에 필요 없는 것은 전부 지연 로드. 3D는 뷰포트 진입 시 동적 import
- 측정하지 않은 최적화는 하지 마라. 대신 예산을 넘겼는지는 반드시 측정해라
6. 산출물 형식
3단계를 마치면 아래가 실제 값으로 채워져 있어야 한다. 이것이 4단계의 입력이다.
:root {
/* 타입 — 비율 ____ , 디스플레이는 스케일 밖 별도 정의 여부 ____ */
--step--1 ~ --step-5, --leading-*, --measure
/* 색 — 역할별. 다크 테마도 함께 */
--surface, --surface-raised, --ink, --ink-muted, --line, --accent, --accent-ink
/* 간격 */
--space-1 ~ --space-8
/* 형태 — 하드 게이트 #3 이 이걸 검사한다 */
--radius-*, --line-width
/* 모션 */
--dur-*, --ease-*
}
그리고 한 줄로: 성능 예산 = JS ___KB / 첫 인터랙션 ___초 / 폰트 패밀리 ___개
정의하지 않은 토큰은 5단계에서 검사할 수 없다. 쓰지 않을 항목은 "쓰지 않음"이라고 적어라 — 빈칸과 "없음"은 다르다.
근거: research/references/03-trends-2026.md, 04-ai-slop-signatures.md (조사일 2026-08-20)