Tooling

디자인 시스템을 만든다면 어떻게 만들까? Tailwind v4 토큰과 다크 모드

면접에서 디자인 시스템을 어떻게 만들 거냐는 질문에 버튼·모달과 색 정의라고만 답했습니다. 사내 관리 도구의 styles.css를 다시 읽으며 뜻으로 지은 색 토큰, 원칙을 어긴 자리의 기록, @theme의 --shadow-*가 다크 모드에서 안 바뀌는 이유를 정리하고, 다시 답한다면 할 답을 적었습니다.

geonoooo

2 min read

며칠 전 면접에서 이런 질문을 받았습니다. "서비스가 여러 개고 레포도 나뉘어 있는데, 서비스마다 조금씩 다른 컴포넌트를 쓰고 있습니다. 디자인 시스템을 만든다면 어떻게 만들 건가요?"

저는 비즈니스 로직이 빠진 버튼이나 모달 같은 공통 컴포넌트를 만들고, 색을 정의하겠다고 답했습니다. 방향은 틀리지 않았지만 질문의 절반에만 답한 셈이었습니다. 질문의 핵심은 "여러 레포"와 "조금씩 다른"이었는데, 저는 무엇을 넣을지만 말했습니다. 서비스마다 다른 부분을 어떻게 흡수할지, 여러 레포에 어떻게 배포할지는 말하지 않았습니다. 버튼과 모달은 누구나 하는 답이었습니다.

면접이 끝나고 예전에 사내 관리 도구 프론트엔드를 만들면서 쓴 styles.css를 다시 열어 봤습니다. 369줄짜리 파일 하나인데, 무엇을 쓰고 무엇을 안 쓸지가 주석으로 적혀 있습니다. 이 글은 그 파일을 다시 읽은 기록이고, 다시 면접을 보게 되면 할 답은 마지막에 적었습니다.

NOTE

이 글의 요점 토큰을 정하는 일의 절반은 무엇을 쓸지 고르는 것이고, 나머지 절반은 무엇을 안 쓸지 정하는 것입니다. 원칙을 어기는 자리는 어긴다고 적고, 되돌릴 조건까지 적습니다. 중간에 Tailwind v4 빌드에서 겪은 문제 세 개가 나옵니다. 빌드 결과는 tailwindcss 4.2.4로 다시 확인했습니다.

같은 프로젝트의 폴더 구조와 lint 설정은 작은 React 프로젝트에도 FSD가 필요할까?에 적었습니다.

선이 아니라 면으로 나눈다

관리 도구 화면은 금방 선으로 뒤덮입니다. 카드에 border, 표 행마다 border-bottom, 섹션 사이에 hr. 하나씩 보면 다 합리적인데 모아 놓으면 화면이 격자로 갈립니다. 그래서 이 파일은 맨 위에 원칙 세 줄을 적고 시작합니다.

/* fe/src/styles.css
 * 경계선이 아니라 면(surface)의 톤 차이와 아주 옅은 그림자로 위계를 만든다.
 * 색은 "선택된 것"(accent)과 "조치가 필요한 것"(warn) 두 곳에만 쓴다.
 * 정상 상태에는 색을 쓰지 않는다.
 */

면은 세 단계입니다. 주석에는 세 값이 서로 달라야 세 단계가 성립한다는 조건도 같이 적혀 있습니다. 밝기를 3%쯤씩 벌렸습니다.

토큰라이트역할
canvas#edeff4바닥
sunken#f4f5f7함몰
surface#ffffff카드

유채색은 네 가지 뜻만 가진다

색을 토큰으로 만들 때 흔한 실수는 색 이름으로 짓는 것입니다. blue-500, red-400. 그러면 "이 배지는 무슨 색이지?"에는 답할 수 있지만 "이 자리에 색을 써도 되나?"에는 답하지 못합니다.

여기서는 유채색 토큰이 넷이고, 토큰마다 뜻이 하나씩 있습니다. 한 토큰을 다른 뜻에 빌려 쓰지 않습니다. 맨 위 주석이 말한 두 곳(accent, warn)에 danger와 ok가 더해진 구성입니다.

토큰뜻쓰는 곳
accent지금 선택된 것선택된 탭, 고른 칩, 포커스 링
warn확인이 필요한 것설정이 비어 있는 항목
danger이미 틀린 것오역 건수, 검증 실패
ok이 일이 끝났다검수 통과율, 완료 배지

warn과 danger를 가른 게 이 표의 핵심입니다. 보통 이 둘은 "노란색/빨간색" 정도로 뭉뚱그려지는데, 주석은 뜻으로 갈라 놓습니다.

/* warn과 뜻이 다르다 — warn은 확인을 요구하고, danger는 이미 잘못된 것을 가리킨다. */

이 구분이 있으면 "시험방법이 아직 설정 안 된 항목"은 warn, "번역 검증에 걸린 문장"은 danger로 논쟁 없이 정해집니다. 구분이 없으면 화면을 만들 때마다 다시 정합니다.

정상 상태에는 색을 쓰지 않는다

보고서 상태는 셋입니다. 진행중, 검토중, 완료. 셋 다 정상입니다. 여기에 파랑/노랑/초록을 붙이는 게 자연스러워 보이지만, 상태 배지는 목록의 모든 행에 붙습니다.

// modules/reports/ui/status-badge.tsx
/*
 * 상태는 색이 아니라 톤과 굵기로 나눈다. 진행중·검토중·완료는 셋 다 정상 상태라
 * 색을 쓰면 목록 전체가 물들고, 정작 조치가 필요한 것이 묻힌다.
 * 지금 살아 있는 것일수록 앞으로 나오고, 끝난 것은 가라앉는다.
 */
const TONE: Record<ReportStatus, string> = {
  "in-progress": "bg-sunken font-semibold text-ink",
  review: "bg-sunken font-medium text-ink-3",
  completed: "font-medium text-ink-4",
};

잉크 램프(ink → ink-4)와 굵기만으로 세 단계를 만듭니다. 완료는 배경도 없습니다.

색을 아끼는 이유는 취향이 아닙니다. 한 화면에서 먼저 눈에 들어오는 자리는 몇 개뿐이고, 색을 칠한 자리가 그 몇 개를 차지합니다. 정상 상태에 색을 쓰면 조치가 필요한 항목이 눈에 띌 자리가 남지 않습니다.

이 규칙은 이미 한 번 깨져 있고, 깨졌다는 사실도 적혀 있습니다.

<!-- fe/CLAUDE.md -->
 
ok는 화면기획이 그렇게 그려져 있어 검수 이력 목록의 모든 행에도 붙는데,
초록이 행마다 깔리면 같은 칸의 오역 건수가 묻힌다. 나중에 좁힐 자리다.

규칙과 현실이 어긋날 때 할 수 있는 건 셋입니다. 규칙을 고치거나, 현실을 고치거나, 어긋났다고 적어 두거나. 세 번째도 임시방편이 아닙니다. 적어 두면 다음에 이 화면을 여는 사람이 "왜 여기만 초록이지?"부터 다시 조사하지 않아도 됩니다.

함정 1 — @theme에 둔 그림자는 다크 모드에서 안 바뀐다

Tailwind v4에서는 토큰을 @theme 블록에 넣는 게 기본입니다. 색도, 반경도, 활자도 거기 넣었습니다. 그림자만 뺐습니다.

/*
 * 그림자만 @theme 밖에 둔다. @theme의 --shadow-*는 Tailwind가 유틸리티에 값을
 * 그대로 인라인해버려서 .dark에서 아무리 덮어써도 바뀌지 않기 때문이다.
 * 색상 토큰은 var() 참조로 나가므로 @theme에 두어도 정상 동작한다.
 */
:root {
  --shadow-card: inset 0 -1px 0 rgb(17 20 26 / 0.05), /* … */;
}
 
@utility shadow-card {
  box-shadow: var(--shadow-card);
}

증상은 이렇습니다. .dark 블록에 --shadow-card를 다시 정의해도 화면이 안 바뀝니다. DevTools로 보면 변수는 제대로 덮여 있습니다. 변수는 맞고 화면은 틀린 상태라 원인을 색상 쪽에서 찾다가 시간을 씁니다.

이유는 빌드 결과에 있습니다. @theme에 --color-accent와 --shadow-card를 두고 빌드하면 이렇게 나옵니다.

.bg-accent {
  background-color: var(--color-accent);
}
 
.shadow-card {
  --tw-shadow:
    inset 0 -1px 0 var(--tw-shadow-color, #11141a0d), 0 1px 2px var(--tw-shadow-color, #11141a0f);
  box-shadow:
    var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow),
    var(--tw-ring-shadow), var(--tw-shadow);
}

색은 var(--color-accent)를 참조하지만 그림자는 var(--shadow-card)를 참조하지 않습니다. Tailwind는 shadow-red-500 같은 그림자 색 유틸리티를 지원하려고 그림자 값을 풀어서 색 자리마다 var(--tw-shadow-color, …)를 끼워 넣습니다. 값이 빌드할 때 유틸리티 안으로 복사되니, .dark에서 --shadow-card를 바꿔도 그 변수를 읽는 곳이 없습니다.

해법은 그림자를 @theme이 아니라 :root에 두고, @utility로 var() 참조를 직접 만드는 것입니다. 위 코드가 그 형태이고, 빌드하면 .shadow-card { box-shadow: var(--shadow-card) }로 나옵니다.

일반화하면, 테마 전환처럼 런타임에 바꿔야 하는 값은 빌드 결과에 var()로 남아 있는지 확인해야 합니다. "변수는 바뀌었는데 화면은 그대로"면 빌드 결과에 값이 복사됐는지부터 보십시오.

함정 2 — 빌드가 바꿔 놓는 CSS

Tailwind v4는 빌드할 때 CSS를 한 번 더 손봅니다. 옛 브라우저용 폴백을 만들고, Lightning CSS로 접두사를 붙이고 겹친 선언을 정리합니다. 이 파일에는 그 과정에서 생긴 문제가 두 군데 적혀 있습니다.

color-mix() 안의 변수는 폴백에서 원색이 된다

바탕 얼룩에 쓰는 강조색을 투명도만 바꿔 여러 번 써야 했습니다. 보통은 color-mix()를 쓰지만, 여기서는 색이 아니라 RGB 세 값을 토큰으로 둡니다.

/* 색이 아니라 RGB 세 값이다. 아래에서 알파만 갈아끼워 쓴다. color-mix로
   쓰면 Lightning CSS가 빌드할 때 계산을 못 하고 원색을 그대로 깐다. */
--tint: 47 107 240;
 
background-image: radial-gradient(65rem 45rem at 5% -10%, rgb(var(--tint) / 0.45), transparent 62%);

주석에는 Lightning CSS라고 적었지만, 다시 빌드해 보니 폴백을 만드는 건 Tailwind 컴파일 단계였습니다. Tailwind가 값을 아는 @theme 변수는 값을 넣어 계산하고, :root에만 있는 변수는 color-mix()를 걷어내고 변수만 남깁니다. :root에 둔 --brand를 섞어 빌드한 결과입니다.

.blob {
  background-image: radial-gradient(65rem 45rem at 5% -10%, var(--brand), transparent 62%);
}
 
@supports (color: color-mix(in lab, red, red)) {
  .blob {
    background-image: radial-gradient(
      65rem 45rem at 5% -10%,
      color-mix(in srgb, var(--brand) 45%, transparent),
      transparent 62%
    );
  }
}

@supports를 통과하는 브라우저는 아래 선언을 쓰니 문제가 없습니다. 통과하지 못하는 브라우저는 위 폴백을 씁니다. 에러는 나지 않고, 45%로 옅어야 할 얼룩이 불투명한 파랑으로 깔립니다. rgb(var(--tint) / 0.45)는 color-mix()가 아니라서 폴백이 생기지 않고 그대로 나갑니다.

-webkit- 접두사를 손으로 쓰면 표준 속성이 지워진다

/* -webkit- 접두사는 직접 쓰지 마라. 손으로 쓰면 Lightning CSS가 그걸
   정답으로 보고 접두사 없는 쪽을 지워버려서 파이어폭스에서 유리가 사라진다. */

이쪽은 주석대로 재현됩니다. 표준 속성만 쓴 것과, 표준 속성 뒤에 접두사를 손으로 붙인 것을 빌드했습니다.

/* 입력 */
.glass-a {
  backdrop-filter: blur(12px) saturate(1.4);
}
.glass-b {
  backdrop-filter: blur(12px) saturate(1.4);
  -webkit-backdrop-filter: blur(12px) saturate(1.4);
}
 
/* 출력 */
.glass-a {
  -webkit-backdrop-filter: blur(12px) saturate(1.4);
  backdrop-filter: blur(12px) saturate(1.4);
}
.glass-b {
  -webkit-backdrop-filter: blur(12px) saturate(1.4);
}

표준 속성만 쓰면 Lightning CSS가 접두사를 알아서 붙입니다. 표준 속성 뒤에 접두사를 쓰면 두 선언을 하나로 합치면서 표준 쪽을 지웁니다. 접두사를 앞에 쓰면 둘 다 남지만, 선언 순서에 기대는 규칙은 누가 줄 순서를 바꾸면 다시 깨집니다. 순서를 지키는 것보다 아예 안 쓰는 쪽이 안전합니다. 호환성을 챙기려고 쓴 한 줄이 호환성을 깨는 경우입니다.

블러 없이 만드는 유리

사이드바와 헤더는 유리판처럼 보이게 만들었습니다. 보통은 backdrop-filter: blur()로 만듭니다. 여기서는 유리처럼 보이는 효과의 대부분이 블러가 아니라 그림자에서 나옵니다.

핵심은 두께입니다. 네 방향 안쪽 테두리를 서로 다른 밝기로 깝니다. 위가 가장 밝고 아래가 가장 어둡습니다. 네 값이 같으면 그냥 테두리 선이 됩니다.

--glass-thickness:
  inset 0 1.5px 0 rgb(255 255 255 / 0.95), inset 1.5px 0 0 rgb(255 255 255 / 0.5),
  inset -1.5px 0 0 rgb(255 255 255 / 0.5), inset 0 -1.5px 0 rgb(255 255 255 / 0.3),
  inset 0 14px 20px -14px rgb(255 255 255 / 0.9), inset 0 -14px 20px -14px rgb(30 48 100 / 0.26);

첫 번째 값이 윗변, 두 번째·세 번째 값이 좌우, 네 번째 값이 아랫변입니다. 마지막 두 값은 위에서 번지는 빛과 아래에서 번지는 그늘입니다.

전부 box-shadow입니다. backdrop-filter는 뒤에 있는 화면을 읽어서 흐리기 때문에 뒤가 바뀔 때마다 다시 계산하지만, box-shadow는 요소 자신만 칠합니다. 뒤가 움직여도 다시 계산할 것이 없습니다.

그림자를 하나의 유틸리티로 합쳐 둔 데도 이유가 있습니다. box-shadow는 속성 하나라서, 그림자 유틸리티 두 개를 한 요소에 붙이면 CSS에서 뒤에 선언된 쪽만 남습니다. shadow-thickness와 shadow-drop을 따로 만들어 같이 붙이면 한쪽이 조용히 사라집니다. 그래서 하나로 합쳤습니다.

그리고 이 유틸리티에는 쓰는 쪽이 지켜야 할 조건까지 적혀 있습니다.

/* 붙이는 요소에 반드시 모서리 반경과 사방 여백을 줘라.
   가장자리에 딱 붙은 직사각형에는 두께가 보이지 않는다. */

원칙을 어긴 자리를 표시해 둔다

유리는 맨 위의 원칙 두 개를 어깁니다. 바탕에 유채색을 깔고(--tint), 가장자리를 선으로 세웁니다(--glass-thickness). "정상 상태에는 색을 쓰지 않는다"와 "선으로 나누지 않는다" 둘 다 위반입니다.

이 위반을 숨기지 않습니다. 어긴다고 쓰고, 되돌리는 방법까지 적어 둡니다.

/* 여기부터 이 파일 위쪽의 원칙 두 개를 일부러 어긴다. (…) 둘 다 유리가 유리로
   보이는 데 필수라 뺄 수가 없다. 유리를 걷어내면 같이 걷어내라. */

이 주석에서 쓸모 있는 건 마지막 문장입니다. "예외입니다"만 적힌 주석은 언제 지워도 되는지 알려 주지 않으니 예외가 계속 남습니다. "유리를 걷어내면 같이 걷어내라"는 --tint와 --glass-thickness를 지울 조건을 유리에 묶어 둡니다.

블러는 두 곳에만 쓴다

블러를 아예 안 쓰는 건 아닙니다. 다만 비용을 식으로 적어 두고 씁니다.

비용: 면적 × 반지름 × 뒤 레이어가 다시 그려지는 횟수

조건도 하나 붙어 있습니다. "뒤로 무늬가 있는 것이 지나가는 곳에만 붙여라." 바탕 얼룩처럼 완만한 그라디언트는 흐려도 같은 그라디언트라서 비용만 들고 효과가 없습니다. 이 조건을 통과한 자리는 둘뿐입니다.

쓰는 곳면적다시 그리는 때
헤더화면의 4.5%스크롤 중에만. 본문이 밑으로 흐른다
모달 오버레이화면 전체열 때 한 번. 뒤가 멈춰 있다

블러에는 saturate()를 같이 겁니다. blur()만 걸면 뒤의 색이 평균으로 섞여 회색에 가까워집니다.

모달은 판이 아니라 뒤에 블러를 겁니다. 모달 판은 불투명하게 두고, 그 뒤를 덮는 오버레이를 흐립니다. 이유는 대비입니다. 판을 반투명하게 하면 그 위에서 읽고 입력해야 하는 폼 글자의 대비가 3.1:1까지 떨어집니다.

같은 이유로 유리는 화면 틀을 이루는 판에만 씁니다. 사이드바, 헤더, 목차 레일. 글을 읽고 쓰는 면(카드, 모달, 편집기)은 불투명하게 둡니다. 반투명한 면 위에서는 글자 대비가 뒤에 무엇이 지나가느냐에 따라 바뀌어서 4.5:1을 보장할 수 없습니다.

투명도 줄이기는 토큰 하나로 처리한다

투명 효과를 줄이도록 설정한 사용자에게는 유리 대신 불투명한 면을 보여 줍니다. 컴포넌트는 아무것도 고치지 않고 --glass-bg 하나만 바꿉니다.

@media (prefers-reduced-transparency: reduce) {
  :root {
    --glass-bg: #ffffff;
  }
  .dark {
    --glass-bg: #20242c;
  }
}

이 블록은 파일에서 원래 .dark 정의보다 뒤에 와야 다크 모드에서도 적용됩니다. :root와 .dark는 특이도가 같아서 선언 순서로 결정됩니다. 이런 실수는 규칙을 몰라서가 아니라 파일 중간에 블록을 끼워 넣다가 생깁니다.

포커스 링은 특이도 0으로 깐다

포커스 링은 반대로 특이도를 낮춥니다. :where()로 감싸 특이도를 0으로 만들어 두면 필요한 곳에서 컴포넌트 클래스 하나로 덮어쓸 수 있습니다.

:where(a, button, input, select, textarea, [tabindex]):focus-visible {
  outline: var(--focus-ring-width) solid var(--color-accent);
  outline-offset: var(--focus-ring-offset);
}

outline은 요소의 border-radius를 따라가므로 모양은 요소마다 맞습니다. :focus-visible이라 버튼과 링크는 마우스로 눌렀을 때는 안 뜨고 키보드로 옮겨 왔을 때만 뜹니다. 텍스트 입력은 브라우저가 클릭에도 링을 띄웁니다.

sr-only 입력을 품은 label에는 focus-ring-within 유틸리티를 따로 만들어 붙입니다. 입력이 sr-only로 잘려 있어 자기 포커스 링을 보여 줄 수 없으니, 사용자가 누르는 판이 대신 링을 받습니다. 두 규칙은 같은 토큰을 읽습니다. 키보드로 옮겨 온 자리는 화면 어디서나 같은 모양이어야 하기 때문입니다.

다크: 바닥을 검정까지 내리지 않는다

다크 팔레트를 만들 때 먼저 하게 되는 일이 배경을 #000에 가깝게 내리는 것입니다. 그러면 카드가 떠 보일 것 같지만 그렇지 않습니다.

/* 바닥을 검정까지 내리지 않는다. 어둠을 더 어둡게 해도 카드가 떠오르지 않는다.
   카드 쪽을 들어올려야 위계가 생기고, 그 위의 글자도 읽힌다. */
토큰다크역할
canvas#0f1216바닥
sunken#171b21함몰
surface#20242c카드

라이트와 같은 순서로 벌립니다. canvas가 가장 어둡고 surface가 가장 밝습니다. 순서가 뒤집히지 않으니 컴포넌트는 두 테마를 구분할 필요가 없습니다.

면이 밝아지면 나머지도 같이 움직여야 합니다. 잉크 램프를 그대로 쓰면 아래 두 단계가 면에 묻힙니다. accent-soft·danger-soft 같은 옅은 배경색은 면이 밝아진 만큼 같이 올려야 면과 구분됩니다. 원색도 밝은 쪽으로 옮깁니다. 라이트의 원색은 어두운 면 위에서 탁해 보입니다.

그림자는 방향이 통째로 뒤집힙니다. 다크에서는 위에 뜬 면이 바닥보다 밝으므로 두께를 아랫변이 아니라 윗변에 줍니다. 유리도 같습니다. 위쪽 하이라이트는 라이트의 1/4로 줄이고 아래쪽은 흰색 대신 검정으로 바꿉니다. 알파도 0.6에서 0.09로 내립니다. 어두운 면에 라이트의 알파를 쓰면 흰 판이 됩니다.

덧붙이면, 이 다크 팔레트는 시안이 있어서 나온 값이 아닙니다. 주석에 그대로 적혀 있습니다. "피그마에 다크 시안이 없어 무채색 램프로 대응한 값이다. 시안이 나오면 이 블록만 교체하면 된다." 토큰으로 나눠 뒀기 때문에 교체가 블록 하나로 끝납니다.

다시 면접을 본다면

면접에서 제 답은 "비즈니스 로직이 빠진 버튼이나 모달 같은 공통 컴포넌트를 만들고, 색을 정의하겠다"였습니다. 방향은 맞지만 빠진 것이 넷입니다.

  • 서비스마다 컴포넌트가 왜 다른지 나누지 않았습니다.
  • 넣는 기준 대신 예시(버튼, 모달)를 들었습니다.
  • 토큰을 색으로만 말했습니다. 이 파일만 해도 면, 잉크, 그림자, 포커스 링이 토큰입니다.
  • 여러 레포에 어떻게 배포하고, 이미 있는 컴포넌트를 어떻게 바꿔 나갈지 말하지 않았습니다.

다시 면접을 보게 되면 이렇게 답하겠습니다.

차이부터 나눈다

먼저 서비스마다 컴포넌트가 왜 다른지 봅니다. 서비스 성격 때문에 일부러 다른 것과, 같은 버튼인데 만든 사람이 달라서 우연히 다른 것을 나눕니다. 일부러 다른 것은 토큰으로 흡수하고, 우연히 다른 것은 하나로 합칩니다.

세 층으로 나눈다

토큰: 색·간격·반경·타이포·그림자. 팔레트 값과 뜻(primary, danger, surface) 두 단계
동작: 모달 포커스 가두기, ESC 닫기, 드롭다운 키보드 이동
컴포넌트: Button, Modal, Input처럼 토큰과 동작을 입힌 것

서비스는 뜻 토큰의 값만 자기 테마로 덮어씁니다. 이 글의 다크 모드가 같은 방식입니다. 같은 토큰 이름에 다른 값을 넣었고, 컴포넌트는 테마를 구분하지 않습니다. 다만 함정 1처럼 빌드할 때 값이 복사되는 토큰은 서비스 테마에서 덮어써도 바뀌지 않으니, 빌드 결과에 var()로 남는지 확인해야 합니다.

동작 층은 서비스마다 다시 만들면 비싸고 접근성 버그가 나는 부분이라 공유할 가치가 가장 큽니다.

도메인 데이터를 아는 컴포넌트는 넣지 않습니다. 이 프로젝트의 상태 배지는 ReportStatus를 알기 때문에 modules/reports/ui/에 있습니다. 디자인 시스템에 들어갈 것은 배지가 아니라 배지가 쓰는 토큰입니다. 넣는 기준은 두 서비스 이상에서 같은 뜻으로 쓰이느냐입니다.

이 글의 styles.css는 이 중 토큰 층입니다. 다만 토큰 값만으로는 부족했습니다. warn과 danger의 뜻, 정상 상태에는 색을 쓰지 않는다는 규칙, 원칙을 어긴 자리처럼 토큰을 쓰는 규칙이 같이 있어야 화면마다 다시 정하지 않습니다.

다른 부분은 조합으로 연다

서비스마다 다른 부분을 prop으로 전부 받으면 prop이 계속 늘어납니다. variant는 디자이너와 합의한 몇 개로 제한하고, 나머지는 children이나 slot 조합으로 열어 둡니다.

배포는 semver, 도입은 하나씩

모노레포면 워크스페이스 패키지로, 레포가 나뉘어 있으면 사내 npm에 semver로 올립니다. 깨지는 변경은 major로 올리고 각 서비스가 자기 일정에 맞춰 올립니다. 화면이 바뀐 것은 Storybook과 시각 회귀 테스트로 PR에서 확인합니다.

도입은 한 번에 갈아엎지 않습니다. 토큰부터 넣고, 중복이 가장 많은 컴포넌트를 서비스 하나에 먼저 적용한 뒤 넓힙니다.

꼬리질문이 오면

  • "A 서비스는 버튼이 둥글고 B는 각졌으면요?" radius 토큰을 서비스 테마에서 덮어씁니다. 컴포넌트 코드는 하나입니다.
  • "직접 만드나요, 라이브러리를 쓰나요?" 접근성 동작은 Radix나 React Aria 같은 headless 라이브러리에 맡기고, 토큰과 스타일만 직접 만듭니다. 의존성이 하나 늘지만 키보드·스크린리더 동작을 직접 구현하는 비용이 빠집니다.
  • "디자이너와는 어떻게 맞추나요?" Figma Variables와 코드 토큰을 한 소스에서 만듭니다.

정리

  • 디자인 시스템을 어떻게 만들지 물으면 무엇을 넣을지보다 차이를 어떻게 흡수할지부터 답하십시오. 서비스 테마 토큰, 동작 공유, 배포 방식이 그 답입니다.
  • 색 토큰은 색 이름이 아니라 뜻으로 지으십시오. red-400은 "이 자리에 색을 써도 되나"에 답하지 못하고 danger는 답합니다.
  • warn과 danger를 가르십시오. 확인을 요구하는 것과 이미 틀린 것은 다른 행동을 부릅니다.
  • Tailwind v4의 @theme에 --shadow-*를 두지 마십시오. 그림자 값이 유틸리티 안으로 복사돼서 .dark에서 바꿔도 안 먹습니다. :root에 두고 @utility에서 var()로 참조하십시오.
  • color-mix()에 Tailwind가 값을 모르는 변수를 넣지 마십시오. 폴백에 원색이 들어갑니다. 투명도만 바꿔 쓸 색은 RGB 세 값 토큰으로 두고 rgb(var(--tint) / 0.45)로 쓰십시오.
  • -webkit- 접두사를 손으로 쓰지 마십시오. 표준 속성 뒤에 쓰면 Lightning CSS가 표준 쪽을 지웁니다.
  • box-shadow 유틸리티를 쪼개지 마십시오. 한 요소에 같이 붙이면 뒤에 선언된 쪽만 남습니다.
  • 글을 읽고 쓰는 면은 불투명하게 두십시오. 반투명 위에서는 대비가 뒤 내용에 따라 변해 4.5:1을 보장할 수 없습니다.
  • 어기는 자리는 어긴다고 적고 되돌릴 조건까지 적으십시오. "유리를 걷어내면 같이 걷어내라" 같은 문장이 예외를 언제 지울지 정해 줍니다.

Comments