디자인 시스템 조합성, 컴포넌트 중심으로 초반 방향성 잡기
도입
이 글에서 퍼블리싱은 화면을 HTML과 CSS로 직접 짜서 브라우저에 보이게 만드는 일을 말한다. 제목의 「포석」도 그 일을 시작할 때 처음 깔아 두는 기본 구조라는 뜻으로 썼다.
개인 사이드 프로젝트에서 디자인 시스템을 도입할 때 가장 어려운 지점은 “처음부터 완벽하게 하려다 멈추는 것”이다. 여기서 조합성이란, 작은 조각(버튼 하나) 여러 개를 레고 블록처럼 그대로 끼워 맞춰서 더 큰 조각(카드, 화면)을 만들 수 있는 성질을 말한다. 조합성이 없으면 카드를 하나 새로 만들 때마다 버튼 스타일부터 다시 짜야 하고, 그렇게 짠 버튼은 다른 카드의 버튼과 모양이 조금씩 달라진다. 이번 글은 HTML(화면의 뼈대)과 CSS(화면의 생김새)만 사용하는 1인 운영 기준으로, 디자인 시스템의 조합성을 확보하는 최소 구조와 작업 순서를 정리한다. 목표는 빠르게 시작하되, 나중에 팀이 커져도 무너지지 않는 기본 뼈대를 만드는 것이다.
토론 요약
디자인 시스템의 조합성을 높이기 위한 핵심은 레이어(화면을 만드는 재료를 “값 → 최소 부품 → 조립된 부품 → 화면 배치”처럼 단계로 나눈 것)를 명확히 나누고, 변형(같은 버튼인데 색만 다른 종류, 예: 저장 버튼과 취소 버튼)과 상태(마우스를 올렸을 때 · 누르고 있을 때처럼 순간마다 바뀌는 모습)를 규칙화하는 것이다. 여기서 컴포넌트란 자바의 클래스처럼 한 번 만들어 두고 여러 곳에서 재사용하는 화면 조각을 말한다 — 다만 클래스는 코드 안에서만 재사용되지만, 컴포넌트는 버튼이나 카드처럼 화면에 실제로 반복해서 나타나는 모양과 그 모양에 필요한 CSS 클래스를 함께 묶어 둔 것이다. HTML/CSS만 쓰면 자바의 new 같은 장치가 없어서, 모양을 정하는 CSS 규칙은 한 번만 쓰고 HTML 쪽에서는 버튼이 필요한 자리마다 <button class="btn"> 같은 태그를 다시 적어 그 클래스를 붙인다. 재사용되는 것은 CSS 규칙이다. CSS 클래스는 자바 클래스와 이름만 같고 전혀 다른 것이다. HTML 태그에 class="btn"처럼 붙이는 이름표이고, CSS는 .btn처럼 점을 앞에 붙여 그 이름표가 달린 요소를 골라 모양을 입힌다(선택자). 컴포넌트 기반으로 운영하려면 토큰(색이나 간격 같은 값에 이름을 붙인 것, 아래에서 디자인 토큰으로 설명한다) → 프리미티브(버튼·입력창처럼 토큰을 가져다 쓰는 가장 작은 부품) → 컴포넌트(프리미티브 여러 개를 조합한 더 큰 부품) → 패턴(컴포넌트 여러 개로 만든 화면 한 구획)의 흐름을 정해두고, 퍼블리싱 단계에서 접근성과 상태를 반드시 챙겨야 한다.
심화 설명: 디자인 시스템 조합성의 레이어 구조
디자인 시스템을 조합성 있게 운영하려면 “레이어”를 나눠야 한다. 다음 구조는 개인 프로젝트에서도 부담 없이 유지 가능한 최소 단위다.
아래 그림은 위에서 아래로 읽는다. 화살표 하나는 “왼쪽 것을 가져다 오른쪽 것을 만든다”는 뜻이다. 즉 Design Tokens(값)로 Primitives(최소 부품)를 만들고, 그 Primitives로 Components(조합된 부품)를, 다시 그 Components로 Patterns(화면 구획)를, 마지막에 그 Patterns를 모아 실제 Pages(화면 한 장)를 만든다는 순서를 보여준다. 이 그림은 글의 원본 파일에 적힌 글자를 브라우저가 그림으로 바꿔 그린 것인데, 원본의 맨 윗줄(%%{init: ...}%%)은 글자 색과 배경색 같은 꾸밈만 정하는 줄이라 화면에는 보이지 않고 내용과도 상관없다.
- Design Tokens(디자인 토큰): 색상, 타이포그래피(글꼴 · 글자 크기 · 줄 간격 같은 글자 모양), 간격, 그림자 등 재사용 단위를 CSS 변수로 고정한다. 토큰은 MDN(Mozilla가 운영하는 웹 기술 문서)이 설명하는 CSS 변수 문법, 즉
--이름: 값으로 선언하고var(--이름)으로 꺼내 쓰는 방식으로 정의한다. 이 문법이 실제로 어떻게 생겼는지는 아래 variables.css에서 한 줄씩 본다. 공식 문서: https://developer.mozilla.org/en-US/docs/Web/CSS/–* , https://developer.mozilla.org/en-US/docs/Web/CSS/var - Primitives(프리미티브): 버튼, 입력창 같은 더는 쪼갤 수 없는 최소 화면(UI) 단위를 만들고, 상태/변형을 명시한다.
- Components(컴포넌트): 프리미티브를 조합한 상위 컴포넌트를 만든다. 아래 예제의 카드가 여기에 든다 — 제목 · 설명 글 · 버튼을 모아 만든 부품이다.
- Patterns(패턴): 컴포넌트 여러 개를 모아 화면 한 구획의 배치(레이아웃)나, 여러 화면에 걸친 흐름(플로우, 예: 회원가입 단계들)을 정의한다.
파일 구조 예시: reset, variables, typography, styles
HTML/CSS만 사용할 때는 파일을 레이어 기준으로 쪼개 두는 것만으로도 조합성이 크게 올라간다. 아래는 최소 구성 예시다. 앞의 레이어와 짝을 지으면 variables.css가 토큰, styles.css가 프리미티브와 컴포넌트를 맡는다. reset.css와 typography.css는 토큰 · 프리미티브 · 컴포넌트 · 패턴 네 레이어 어디에도 딱 들어가지 않는다(그림 맨 끝의 Pages는 레이어가 아니라 이 넷으로 만든 결과 화면이다). 모든 레이어 밑에 깔리는 바닥(브라우저 기본값 지우기, 글자 기본 모양)이다. 이 예시는 파일이 넷뿐이라 패턴용 파일은 따로 없다.
styles/
reset.css
variables.css
typography.css
styles.css
파일을 한 덩어리로 몰아 쓰지 않고 이렇게 나누는 이유는, 나중에 “간격 값을 바꾸고 싶다”처럼 레이어 하나만 고칠 일이 생겼을 때 그 레이어의 파일 하나만 열면 되기 때문이다. 네 파일 다 합쳐도 결국 하나의 화면에 적용돼야 하므로, 실제 HTML 문서의 <head>(화면에 보이지 않는 설정을 적는 문서의 머리 부분) 안에서 다음처럼 순서대로 불러온다.
<link rel="stylesheet" href="styles/reset.css">
<link rel="stylesheet" href="styles/variables.css">
<link rel="stylesheet" href="styles/typography.css">
<link rel="stylesheet" href="styles/styles.css">
<link> 태그 하나가 CSS 파일 하나를 불러오는 역할을 하고, 브라우저는 이 태그들을 위에서 아래로 쓰인 순서 그대로 읽어 적용한다. 태그 안의 href="..."는 불러올 파일의 주소이고, rel="stylesheet"는 「이 파일은 이 문서에 입힐 스타일시트(CSS 파일)다」라는 관계 표시다(이렇게 태그 안에 이름="값"으로 붙는 것을 HTML 속성이라 한다). 이 순서가 왜 중요한지는 아래 styles.css 코드 뒤의 「나중에 읽은 파일이 이긴다」 문단에서 다시 설명한다.
CSS 한 덩어리는 「누구에게 · 무엇을 · 얼마로」로 읽는다
아래 네 파일을 읽기 전에 CSS 문법 하나만 알면 된다. CSS 규칙은 전부 이 모양이다.
선택자 {
속성: 값;
}
중괄호 앞의 선택자는 「누구에게 적용할지」다. body처럼 태그 이름을 쓰면 그 태그 전부를, .btn처럼 점으로 시작하면 class="btn"이 붙은 요소를 고른다(선택자). 중괄호 안의 한 줄 한 줄은 「무엇을 얼마로 바꿀지」다. margin: 0;은 margin이라는 속성을 0으로 바꾼다는 뜻이다.
여기서 요소는 HTML 태그 하나로 만들어진 화면 조각이다. <div><p>글</p></div>처럼 태그 안에 태그를 넣으면 바깥 div를 안쪽 p의 부모 요소라 부른다. HTML 문서는 이렇게 맨 바깥 <html>부터 안으로 들어가는 나무 모양이다.
reset.css
*, *::before, *::after {
box-sizing: border-box;
}
html {
line-height: 1.15;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
}
h1, h2, h3, h4, h5, h6, p {
margin: 0;
}
ul, ol {
margin: 0;
padding: 0;
list-style: none;
}
img, svg, video, canvas {
display: block;
max-width: 100%;
}
button, input, textarea, select {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
한 줄씩 따라가기: reset.css는 브라우저마다 기본으로 깔고 들어가는 스타일(여백, 글머리 기호 등)이 서로 달라서 생기는 차이를 먼저 지우는 파일이다. 지우지 않으면 똑같은 HTML이 브라우저마다 다르게 보인다.
*, *::before, *::after { box-sizing: border-box; }: 모든 요소에 적용된다는 뜻의*를 써서, 요소의 너비를 계산할 때padding과border까지 포함해서 계산하게 만든다. 요소 하나는 안에서부터 내용 →padding(내용과 테두리 사이의 안쪽 여백) →border(테두리) →margin(테두리 바깥, 다른 요소와의 바깥 여백) 순으로 감싸여 있다. 이 줄이 없으면width: 100px이라고 적어도 안쪽 여백과 테두리만큼 실제 크기가 더 커진다.::before와::after는 CSS가 요소 안의 맨 앞과 맨 뒤에 만들어 넣을 수 있는 보이지 않는 가짜 조각이다.*만으로는 이 조각들이 빠지므로 같이 적었다.html { line-height: 1.15; -webkit-text-size-adjust: 100%; }: 줄 간격의 기준값을 통일하고, 일부 모바일 브라우저가 화면을 돌렸을 때 글자 크기를 제멋대로 키우는 동작을 막는다.line-height: 1.15는 줄 높이를 글자 크기의 1.15배로 한다는 뜻이다. 앞에 붙은-webkit-는 크롬 · 사파리 계열 브라우저가 실험 중이거나 비표준인 기능에 붙이던 꼬리표(벤더 접두사)다.body { margin: 0; }와h1~h6, p { margin: 0; }: 브라우저가 기본으로 넣어 두는 바깥 여백(margin)을 전부 0으로 만든다. 이 여백은 브라우저마다 값이 달라서, 지워 두고 내가 필요한 만큼만 직접 지정하는 쪽이 예측하기 쉽다.ul, ol { margin: 0; padding: 0; list-style: none; }: 목록 앞에 자동으로 붙는 점(•)이나 번호를 없애고 여백도 지운다. 디자인 시스템에서는 목록 모양을 컴포넌트가 직접 정하기 때문이다.img, svg, video, canvas { display: block; max-width: 100%; }: 이미지 같은 요소 아래에 생기는 미세한 빈틈을 없애고, 부모 요소보다 커지지 않게 막는다.display는 요소가 화면에 놓이는 방식을 정하는 속성이고,block은 「글자 사이에 끼지 않고 새 줄에서 시작하는 한 덩어리로 놓는다」는 값이다. 그렇게 놓으면 왜 빈틈이 사라지는지는 이 글에서 다루지 않는다.button, input, textarea, select { font: inherit; }: 버튼이나 입력창은 브라우저가 기본 글꼴을 따로 정해 두는데, 이 줄이 그걸 지우고 주변 글자와 같은 글꼴을 쓰게 만든다.inherit는 「부모 요소의 값을 그대로 물려받는다」는 뜻이라, 버튼이 자기를 감싼 요소의 글꼴을 따라간다.a { color: inherit; text-decoration: none; }: 링크의 파란 글자색과 밑줄을 지운다. 색과 밑줄을 컴포넌트가 직접 정하게 하려는 목적이다.
variables.css
:root {
--color-bg: #0b0f14;
--color-surface: #111827;
--color-text: #e5e7eb;
--color-muted: #94a3b8;
--color-accent: #22c55e;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--radius-1: 6px;
--radius-2: 10px;
--shadow-1: 0 2px 10px rgba(0, 0, 0, 0.18);
}
한 줄씩 따라가기: :root는 이 HTML 문서 전체를 감싸는 최상위 요소(나무의 맨 위인 <html>)를 가리키는 선택자다. 여기에 값을 선언해 두면 문서 어디서든 꺼내 쓸 수 있어서, CSS 변수를 선언하는 자리로 관례적으로 쓴다. html이라고 써도 같은 요소를 고르지만, MDN에 따르면 :root가 html보다 특이도가 높다. 그래서 변수 선언은 :root에, 글자 크기 같은 일반 스타일은 typography.css처럼 html에 적었다. --color-bg처럼 이름이 --로 시작하는 것은 “이건 내가 만든 CSS 변수”라는 표시다 — color-bg처럼 -- 없이 쓰면 브라우저가 진짜 CSS 속성 이름으로 착각한다. --space-1: 4px 같은 줄은 “4px이라는 값에 --space-1이라는 이름을 붙였다”는 뜻이고, 다른 CSS 파일에서는 var(--space-1)이라고 써서 이 값을 꺼내 쓴다.
#0b0f14같은 색 값은#뒤 여섯 글자를 두 글자씩 끊어 빨강 · 초록 · 파랑의 세기를 16진수(00~ff, 즉 0~255)로 적은 것이다. 16진수는 0~9 다음에 a(10)~f(15)를 쓰고 한 자리 올라갈 때마다 16배가 되는 셈법이라,ff는 15×16+15=255,0b는 11,0f는 15,14는 1×16+4=20이다. 셋 다 255에 비해 아주 작아서 거의 검은색(파랑이 조금 더 센 남색 기운)이 된다.--space-1~--space-6의 숫자는 4px의 몇 배인지다(1=4px, 2=8px, 3=12px, 4=16px, 6=24px). 5(20px)는 이 예시에 없다.--shadow-1: 0 2px 10px rgba(0, 0, 0, 0.18)의 숫자 셋은 차례로 그림자를 옆으로 옮길 거리(0), 아래로 옮길 거리(2px), 흐리게 퍼뜨릴 정도(10px)다.rgba(0, 0, 0, 0.18)은 빨강 · 초록 · 파랑이 모두 0인 검은색을, 마지막 0.18(0이면 완전히 투명, 1이면 완전히 불투명)만큼만 비치게 칠한다는 뜻이다. 그래서 카드 아래에 옅은 그림자가 깔린다.--radius-1·--radius-2는 아래border-radius(모서리를 둥글게 깎는 속성)에 쓸 둥근 정도다.
typography.css
html {
font-size: 16px;
}
body {
font-family: "Pretendard", "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
font-size: 1rem;
line-height: 1.6;
color: var(--color-text);
background: var(--color-bg);
}
.t-title {
font-size: 1.5rem;
line-height: 1.3;
font-weight: 700;
}
.t-body {
font-size: 1rem;
line-height: 1.6;
}
.t-caption {
font-size: 0.875rem;
line-height: 1.4;
color: var(--color-muted);
}
한 줄씩 따라가기: rem은 글자 크기를 나타내는 단위로, 최상위 요소 <html>의 글자 크기의 몇 배인지를 나타낸다. 여기서는 html { font-size: 16px; }로 그 기준을 16px로 정했으니 1.5rem은 16px의 1.5배인 24px이고, 0.875rem은 14px이다. 글자 크기를 전부 rem으로 적어 두면 기준 한 곳만 바꿔도 모든 글자가 비율대로 함께 바뀐다.
다만 이 파일처럼 기준을 16px로 못 박으면, 사용자가 브라우저 설정에서 기본 글자 크기를 키워도 기준은 16px 그대로라 rem 글자도 커지지 않는다. MDN은 px 글자 크기가 일부 브라우저에서 사용자가 크기를 바꿀 수 없게 만들어 접근성에 좋지 않다고 적고 있다(https://developer.mozilla.org/en-US/docs/Web/CSS/font-size). 사용자 설정을 따라가게 하려면 html의 font-size를 px로 정하지 않아야 한다 — 아무것도 정하지 않으면 기준은 브라우저 기본 글자 크기(대개 16px)가 된다. 위 코드는 처음 쓴 그대로 16px로 두었다. 사용자 설정을 따르게 하려면 html { font-size: 16px; } 한 덩어리를 지우면 된다.
font-family에 글꼴을 여러 개 적는 것은 대비책 목록이다. 브라우저는 앞에서부터 그 컴퓨터에 있는 글꼴을 골라 쓴다. 맨 끝의sans-serif는 특정 글꼴 이름이 아니라 「획 끝에 장식이 없는 글꼴 아무거나」라는 분류 이름이라, 앞의 셋이 다 없어도 비슷한 글꼴이 나온다.line-height: 1.6은 줄 높이를 글자 크기의 1.6배로,font-weight: 700은 굵은 글씨(bold, 보통 굵기는 400)로 한다는 뜻이다..t-title·.t-body·.t-caption은 점으로 시작하니 클래스 선택자다. 앞의t-는 글자(text) 모양용 클래스라는 표시로 붙인 이름이다. HTML에서<h1 class="t-title">처럼 붙여 쓴다.
styles.css
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-1);
border: 1px solid transparent;
background: var(--color-surface);
color: var(--color-text);
}
.btn--primary {
background: var(--color-accent);
color: #0b0f14;
}
.card {
padding: var(--space-4);
border-radius: var(--radius-2);
background: var(--color-surface);
box-shadow: var(--shadow-1);
}
한 줄씩 따라가기:
display: inline-flex는 이 요소(버튼) 안에 있는 내용(아이콘과 글자 등)을 한 줄로 나란히 배치하되, 버튼 자신은 다른 글자나 버튼 옆에 나란히 놓일 수 있게 한다.align-items: center는 그렇게 나란히 놓인 내용들을 세로 방향 가운데로 맞추고,gap: var(--space-2)는 내용들 사이에 일정한 간격(앞서 정의한--space-2, 즉 8px)을 띄운다.padding: var(--space-2) var(--space-4);처럼 값을 둘 적으면 앞의 값(8px)이 위아래, 뒤의 값(16px)이 왼쪽 오른쪽 안쪽 여백이다. 그래서 버튼이 옆으로 조금 더 넓적해진다.border-radius: var(--radius-1)은 모서리를 6px만큼 둥글게 깎고,.card의box-shadow: var(--shadow-1)은 variables.css에서 정한 옅은 그림자를 카드 아래에 깐다.border: 1px solid transparent;는 1px 두께의 실선 테두리를 달되 색을 투명하게 해서 안 보이게 한다. 안 보이는 테두리를 굳이 다는 이유는 아래 「실무 적용 예제」의.btn--ghost에서 나온다 — 모든 버튼이 같은 두께의 테두리를 미리 갖고 있으니, 변형은 테두리 색만 바꾸면 되고 버튼 크기는 그대로다..btn--primary의color: #0b0f14;는 토큰을 쓰지 않고 값을 바로 적었다. 이 값은--color-bg와 같으니var(--color-bg)로 적어도 같은 색이 나온다..btn--primary처럼 클래스 이름에 하이픈 두 개(--)가 들어간 것은 “기본.btn에서 색만 다르게 바꾼 변형”이라는 뜻으로 붙이는 이름 규칙이다. 뒤의 “실무 적용 예제”에서 이 규칙을__(부품 표시)까지 포함해 더 자세히 설명한다.
CSS는 같은 요소에 같은 세기(특이도 — 선택자 모양으로 정해지는 우선순위 점수)의 규칙이 여러 번 걸리면, 나중에 읽은 파일의 규칙이 앞서 읽은 파일의 규칙을 덮어쓴다. 예를 들어 reset.css에는 body { margin: 0; }이 있다. 나중에 styles.css에 body { margin: 24px; }처럼 화면 가장자리 여백을 더했다고 하자(이 글의 styles.css에는 아직 없는 줄이다). 둘 다 body라는 같은 선택자라 세기가 같다. 이때 styles.css를 reset.css보다 먼저 불러오면, 나중에 읽히는 reset.css의 margin: 0이 styles.css에서 잡아 둔 24px를 다시 지워 버린다. 그래서 실제 프로젝트에서는 reset, variables, typography를 먼저 로드하고, 레이어로 보면 가장 구체적인 styles(컴포넌트 스타일)를 마지막에 로드하면 안전하다. 여기서 「구체적」은 문서 전체가 아니라 버튼 · 카드처럼 특정 부품에만 걸리는 규칙이라는 뜻이다. 바닥 규칙을 먼저 깔고 부품 규칙을 나중에 얹어야 부품 규칙이 지워지지 않는다.
토큰 설계의 최소 규칙
CSS 변수는 디자인 시스템의 조합성을 확보하는 가장 작은 단위다. 변수만 고정하면 컴포넌트는 얼마든지 조합할 수 있다. 버튼과 카드가 둘 다 var(--space-4) · var(--color-surface) 같은 같은 이름의 값을 꺼내 쓰므로, 어떤 버튼을 어떤 카드 안에 넣어도 간격과 색이 서로 어긋나지 않기 때문이다.
아래 코드는 앞의 variables.css에서 꼭 필요한 줄만 남긴 것이다. 처음 시작할 때 이 정도(배경 · 면 · 글자 · 강조 색, 간격 네 단계, 모서리 두 단계)만 정해 두면 된다는 뜻으로 다시 적었다. --color-muted · --space-6 · --shadow-1은 필요해지면 그때 더한다.
:root {
--color-bg: #0b0f14;
--color-surface: #111827;
--color-text: #e5e7eb;
--color-accent: #22c55e;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--radius-1: 6px;
--radius-2: 10px;
}
이 방식은 MDN의 CSS custom properties(이 글에서 말한 CSS 변수의 영어 이름) 문서에 정의된 방식과 동일하게 동작한다. 공식 문서: https://developer.mozilla.org/en-US/docs/Web/CSS/–* , https://developer.mozilla.org/en-US/docs/Web/CSS/var
상태와 접근성: :focus-visible, prefers-reduced-motion
퍼블리싱 단계에서 조합성이 깨지는 가장 흔한 이유는 상태 규칙이 빠지는 것이다. 버튼 프리미티브에 「초점이 맞았을 때」 모양을 정해 두지 않으면, 그 버튼을 가져다 쓰는 카드마다 제각각 모양을 덧붙인다. 그러면 같은 버튼이 카드마다 다르게 반응하고, 그 버튼은 더 이상 어디에 끼워도 똑같은 블록이 아니게 된다.
:focus-visible은 마우스로 클릭했을 때는 테두리(outline)를 보여주지 않고, 키보드의 Tab 키로 이동해서 초점이 맞았을 때만 테두리를 보여주는 선택자다. 여기서 outline은 앞의 border와 다른 테두리다. border는 요소 크기에 포함되지만, outline은 border 바깥에 그려질 뿐 자리를 차지하지 않아서 나타났다 사라져도 주변 배치가 움직이지 않는다. 마우스 사용자에게는 불필요한 테두리를 숨기면서도, 마우스를 쓰기 어려워 키보드로 화면을 탐색하는 사용자에게는 “지금 어디에 초점이 있는지”를 보여줘야 하기 때문에 키보드 접근성을 위해 반드시 포함한다. 공식 문서: https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visibleprefers-reduced-motion은 움직이는 화면을 보면 어지러움이나 멀미를 느끼는 사용자가 운영체제 설정에서 “동작 줄이기”를 켰는지를 CSS가 확인할 수 있게 해 주는 미디어 쿼리다. 그 설정이 켜져 있으면 이 규칙이 애니메이션과 화면 전환 효과의 길이를 사실상 0으로 줄여서, 화면이 움직이는 느낌 자체를 없애 준다. 모션을 줄여야 하는 사용자를 위해 이를 고려한다. 공식 문서: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion, https://developer.mozilla.org/en-US/docs/Web/CSS/@media
.btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
한 줄씩 따라가기:
.btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }는 키보드로 버튼에 초점이 맞았을 때, 강조 색으로 된 2px 두께의 테두리를 버튼 바깥쪽으로 2px 띄워서 그려 준다..btn뒤에 콜론(:)으로 붙인focus-visible은 「.btn중에서 지금 이 상태인 것만」이라는 조건이다. 클래스는 앞의 버튼과 같은.btn이라, 이 규칙 하나로 글 안의 모든 버튼에 초점 테두리가 생긴다.@media (prefers-reduced-motion: reduce) { ... }는 “동작 줄이기 설정이 켜져 있을 때만” 안의 규칙을 적용하라는 뜻이다. 그 안의*는 페이지의 모든 요소를 가리키고,animation-duration: 0.001ms는 애니메이션이 진행되는 시간을 0.001밀리초(사실상 순간)로 줄인다.animation-iteration-count: 1은 애니메이션을 몇 번 반복할지를 1번으로 묶는다. 계속 반복하도록 만든 애니메이션이 있으면 시간만 줄여서는 짧게 깜빡이며 계속 되풀이되므로, 횟수도 1번으로 막는다.transition-duration은 애니메이션과 비슷하지만 다른 장치인 트랜지션(색이나 크기가 바뀔 때 한순간에 바뀌지 않고 서서히 바뀌게 하는 효과)이 걸리는 시간이고, 이것도 0.001밀리초로 줄인다. 0 대신 0.001ms를 쓴 이유는 이 글에서 확인하지 않았다. 끝에 붙은!important는 “다른 어떤 규칙보다 이 값을 우선해서 적용하라”는 표시로, 컴포넌트마다 제각각 정해 둔 애니메이션 시간 설정을 이 규칙 하나가 확실히 덮어쓰게 하려고 붙인다.
실무 적용 예제: HTML/CSS만으로 조합성 있는 컴포넌트 만들기
아래는 “버튼 프리미티브 → 카드 컴포넌트”의 최소 구조 예시다.
<button class="btn btn--primary">Save</button>
<button class="btn btn--ghost">Cancel</button>
<div class="card">
<h3 class="card__title">Title</h3>
<p class="card__desc">Description</p>
<div class="card__actions">
<button class="btn btn--primary">Action</button>
</div>
</div>
한 줄씩 따라가기: 클래스 이름에 붙은 __와 --는 BEM이라는 이름 규칙이다. card__title처럼 밑줄 두 개(__)는 “이 요소가 card라는 컴포넌트에 속한 부품이다”를 나타낸다. btn--primary처럼 대시 두 개(--)는 “이 요소가 btn의 색깔만 다른 변형이다”를 나타낸다. 이 규칙을 지키면 클래스 이름만 보고도 “이게 어느 컴포넌트의 어느 부품이고 어떤 변형인지”를 알 수 있어서, CSS 파일이 커져도 어떤 클래스가 어디에 쓰이는지 찾기 쉬워진다.
HTML 쪽에서 볼 것은 셋이다.
<button class="btn btn--primary">처럼class안에 이름을 띄어 쓰면 두 클래스가 한꺼번에 적용된다. 기본 모양.btn에 색 변형.btn--primary를 얹는 것이고, 이 「클래스 겹쳐 붙이기」가 이 글이 말하는 조합의 실제 모습이다.<div>는 아무 뜻 없이 여러 요소를 하나로 묶는 상자,<h3>은 3단계 제목,<p>는 문단이다.- 카드 안의
card__actions상자에 버튼을 그대로 넣었다. 카드 CSS는 버튼 모양을 하나도 정하지 않는다. 버튼 모양은.btn이 이미 들고 있으니 카드는 자리만 내준다.card__title·card__desc·card__actions는 아래 CSS에 규칙이 없다. 지금은 「카드의 제목 · 설명 · 버튼 자리」라는 이름표일 뿐이고, 모양을 바꾸고 싶어지면.card__title { ... }처럼 규칙을 더해 그 이름표로 고른다.
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-1);
border: 1px solid transparent;
background: var(--color-surface);
color: var(--color-text);
}
.btn--primary {
background: var(--color-accent);
color: #0b0f14;
}
.btn--ghost {
background: transparent;
border-color: var(--color-text);
}
.card {
padding: var(--space-4);
border-radius: var(--radius-2);
background: var(--color-surface);
color: var(--color-text);
}
.btn--ghost는 배경을 투명하게(transparent) 비우고 테두리만 글자색으로 보이게 한 변형이다. 앞의 styles.css에서 .btn이 투명한 1px 테두리를 미리 달아 두었기 때문에, 여기서는 border-color로 색만 바꿔도 테두리가 나타나고 버튼 크기는 Save 버튼과 같다. .card는 앞의 styles.css 예시와 조금 다르다(그림자가 빠지고 글자색이 들어갔다). 차이에 따로 이유를 두지는 않았고, 어느 쪽이든 토큰만 꺼내 쓴다는 점은 같다.
이 구조는 “토큰 → 프리미티브 → 컴포넌트” 흐름을 명확히 보여준다. CSS에서 .btn과 .card는 둘 다 값을 var(...)로 토큰에서 꺼내 쓰고(토큰 → 프리미티브 · 컴포넌트, .btn--primary의 글자색 #0b0f14 하나만 값을 바로 적었다), 카드가 버튼을 품는 조합은 CSS가 아니라 HTML에서 카드 상자 안에 버튼을 넣는 것으로 일어난다(프리미티브 → 컴포넌트). 컴포넌트를 늘리더라도 동일한 규칙을 반복하기 때문에 조합성이 유지된다.
주의할 점: 퍼블리싱에서 흔히 깨지는 포인트
아래 내용은 개인 프로젝트 경험을 바탕으로 정리한 팁이며, 공식 문서 근거가 없는 경험적 가이드다.
- 상태 정의 누락: hover(마우스를 올렸을 때)/focus(키보드나 클릭으로 초점이 맞았을 때)/disabled(눌러도 반응하지 않게 비활성화됐을 때)/active(마우스 버튼을 누르고 있는 순간)를 한 번에 작성하지 않으면 컴포넌트가 쉽게 분열된다. 분열이란 같은 버튼이 쓰이는 자리마다 다른 상태 모양을 갖게 되어, 사실상 여러 종류의 버튼으로 갈라지는 것이다.
- 네이밍 규칙 불일치: 클래스 네임 규칙이 흔들리면 조합성이 빠르게 망가진다. 예를 들어 한쪽에서는
btn--primary, 다른 쪽에서는button-primary라고 지으면 같은 버튼이 두 벌의 CSS 규칙으로 갈라진다. 버튼 색을 바꿀 때 두 곳을 다 고쳐야 하고, 하나를 빠뜨리면 버튼 모양이 어긋난다. - 레이아웃 책임 혼재: 컴포넌트 내부에서 레이아웃까지 책임지면 재사용성이 낮아진다. 예를 들어
.card에 「위 카드와 24px 띄운다」 같은 바깥 여백(margin)이나 「화면 오른쪽에 붙는다」 같은 위치를 적어 두면, 그 카드를 다른 자리에 놓을 때마다 그 값을 지우는 규칙을 또 써야 한다. 카드는 자기 안쪽 모양만 정하고, 어디에 놓일지는 카드를 담는 패턴이 정한다. 패턴의 코드 예시는 이 글에서 다루지 않았다. - 반응형 기준 부재: 반응형은 화면 너비(휴대폰 · 노트북)에 따라 배치가 알맞게 바뀌는 화면을 말한다. 브레이크포인트란 미디어 쿼리에서 “화면 너비가 몇 px 이하일 때 스타일을 바꾼다”의 그 기준 너비 값을 말한다. 이 값을 컴포넌트마다 제각각 정하면(어떤 곳은 768px, 어떤 곳은 800px) 화면 크기가 같은데도 컴포넌트마다 다른 지점에서 레이아웃이 바뀌어 패턴이 무너진다.
결론/팁
디자인 시스템은 “완성”이 아니라 “규칙의 최소화”에서 시작된다. 개인 사이드 프로젝트라면 토큰과 프리미티브까지만 고정해도 조합성은 충분히 확보할 수 있다. 이후 컴포넌트와 패턴을 늘리면서 규칙을 확장하면 된다.
마지막으로 질문 하나를 남긴다. 디자인 시스템을 만들 때 “토큰 설계”와 “컴포넌트 규칙화” 중 어디에서 가장 막히는가.
자신만의 철학을 만들어가는 중입니다.
댓글남기기