← 목록으로
AI-assisted content
모바일 디자인 시스템에서 CSS를 의도의 언어로 다루기

들어가며

CSS는 시각적 결과만 맞으면 통과하는 코드처럼 다뤄지기 쉽습니다. padding: 12px 16px은 분명히 동작합니다. 그러나 이 코드는 "왜 12px인지", "왜 좌우와 상하가 다른지", "다른 언어가 들어오면 괜찮은지"에 대해 아무것도 말하지 않습니다.

같은 시각적 결과를 내는 두 가지 CSS가 있다면, 의도가 코드에서 읽히는 쪽을 골라야 합니다. 디자인 시스템에서는 더더욱 그렇습니다. 컴포넌트 한 번의 결정이 호출부 수십 곳에 전파되기 때문입니다.

이 글은 모바일 webview 디자인 시스템을 만들 때 알고 있어야 하는 일곱 가지 CSS 패턴을 다룹니다. 모두 "동작은 같지만 의도가 다른" 선택지에 대한 이야기입니다.

  1. 물리적 좌표 대신 논리적 흐름 — 국제화를 위한 logical properties
  2. display로 콘텐츠의 본질 선언 — inline-block vs inline-flex
  3. 접근성은 컴포넌트 API에 내장 — 조건부 aria-hidden
  4. transparent는 색이지 "없음"이 아니다 — transition을 위한 valid color
  5. inherit로 user-agent stylesheet 길들이기 — 폼 요소의 강제 기본값
  6. min-width: 0 — flex item의 implicit min-content
  7. 비대칭 transition — 모바일 네이티브 같은 터치감의 핵심

1. 물리적 좌표 대신 논리적 흐름

다음 CSS는 LTR 언어를 전제로 작성된 코드입니다.

.divider--inset-none {
  margin-left: 0;
  margin-right: 0;
}
.divider--inset-content {
  margin-left: 16px;
  margin-right: 16px;
}
.divider--inset-leading {
  margin-left: 56px;
}

margin-left는 화면의 왼쪽을 직접 지정합니다. "왼쪽"이라는 개념 자체가 LTR을 전제로 합니다. 아랍어·히브리어 같은 RTL 언어가 들어오면 의미가 뒤집힙니다. 디자인 시스템의 "leading inset"은 본래 "텍스트가 시작되는 쪽 들여쓰기"라는 의미인데, RTL에서는 오른쪽이 시작점이 되어야 합니다.

CSS Logical Properties로 옮기면 이 모호함이 사라집니다.

.divider--inset-none {
  margin-inline: 0;
}
.divider--inset-content {
  margin-inline: 16px;
}
.divider--inset-leading {
  margin-inline-start: 56px;
}

margin-inline-start는 "텍스트가 시작되는 쪽 여백"입니다. LTR에서는 left, RTL에서는 right로 자동 매핑되므로 스타일 정의는 한 번만 작성하면 됩니다.

물리적 속성논리적 속성의미
margin-leftmargin-inline-start텍스트 시작 쪽 여백
margin-rightmargin-inline-end텍스트 끝 쪽 여백
padding-toppadding-block-start행이 시작되는 쪽 안쪽 여백
padding-bottompadding-block-end행이 끝나는 쪽 안쪽 여백
margin-left + margin-rightmargin-inline양쪽 inline 방향
padding-top + padding-bottompadding-block양쪽 block 방향

핵심은 "화면의 좌표"가 아니라 "콘텐츠의 흐름"으로 표현한다는 것입니다. CSS 명세에서 inline은 글이 흘러가는 방향, block은 줄이 쌓이는 방향입니다. 한국어·영어처럼 가로쓰기·LTR에서는 inline = 가로, block = 세로지만, writing-mode가 바뀌면 자동으로 따라옵니다.

i18n 계획이 없는 프로젝트라도 로지컬 속성을 기본값으로 쓰는 것이 맞습니다. 속성 이름 자체가 "이 마진이 무엇을 위한 마진인지" 설명하기 때문입니다. margin-left는 "왼쪽에 16px"이지만, margin-inline-start는 "리딩 인셋"이라는 디자인 의도를 코드로 표현합니다.

2. display로 콘텐츠의 본질 선언

색칠된 원을 만드는 Dot 컴포넌트를 생각해 봅시다. 자식 없이 배경색과 border-radius: 50%로 원을 그리는 leaf 요소입니다.

.dot {
  display: inline-block;
  flex-shrink: 0;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: currentColor;
}

display: inline-block 대신 inline-flex로 바꿔도 시각적으로는 똑같이 보입니다. 둘 다 인라인 박스이고, 너비·높이도 그대로 적용됩니다. 그렇지만 둘은 같은 코드가 아닙니다.

flex는 자식을 배치하는 포매팅 컨텍스트입니다. 자식이 없는 요소에 flex를 지정하면 "이 안에 뭔가를 배치할 거야" 라는 잘못된 신호를 줍니다. 코드만 보고도 "이건 끝점이구나" 라는 게 읽히려면 inline-block이 맞습니다. 시각 결과가 같은 여러 선언 중에서 요소의 본질을 가장 잘 설명하는 값을 골라야 합니다.

flex-shrink: 0이 함께 있어서 헷갈리기 쉬운데, 이건 별개의 컨텍스트에서 동작하는 속성입니다.

<div style="display: flex">
  <span class="dot"></span>           <!-- Dot이 flex item -->
  <p>긴 텍스트...</p>
</div>

flex-shrink자기 자신이 flex 자식일 때 적용됩니다. 부모의 공간이 부족할 때 텍스트는 줄어들어도 Dot은 원형을 유지해야 하므로 필요합니다. Dot 자신의 display와는 무관한 속성입니다.

속성역할누구의 컨텍스트?
display: inline-blockDot 자신을 인라인 박스로 렌더Dot 자신
flex-shrink: 0Dot이 flex 부모 안에서 안 줄어들게부모 flex

두 속성이 겉으로는 비슷해 보여도 다른 레이어의 결정이라는 것을 분리해서 표현해야 합니다.

3. 접근성은 컴포넌트 API에 내장

Dot 컴포넌트의 접근성 처리는 다음과 같은 형태가 정석입니다.

function Dot({ label, className }: DotProps) {
  return (
    <span
      className={`dot ${className ?? ''}`}
      aria-label={label}
      aria-hidden={label ? undefined : true}
    />
  );
}

aria-hidden이 호출부의 label 전달 여부에 따라 자동 분기됩니다. 이유는 Dot이 두 가지 다른 의미로 쓰이기 때문입니다.

장식용: 텍스트 옆에 보조로 붙는 경우입니다.

<div class="row">
  <span class="dot dot--success"></span>
  <p>활성화됨</p>
</div>

이 경우 dot은 "활성화됨"을 시각적으로 강조하는 색 칩일 뿐이고, 의미는 옆 텍스트가 전달합니다. 스크린리더가 dot까지 읽으면 "그래픽, 활성화됨"처럼 중복 노이즈가 발생합니다.

의미 전달용: dot 단독으로 정보를 전달합니다.

<span class="dot dot--danger" aria-label="읽지 않은 알림 있음"></span>

텍스트 없이 빨간 점 하나로 "알림 있음"을 표현하는 경우, 스크린리더 사용자는 라벨이 없으면 정보를 인지할 수 없습니다.

이 두 경우를 매번 호출부가 신경 쓰게 만들면 누락이 발생합니다. "라벨이 있다 = 의미 있는 요소 / 없다 = 장식" 이라는 규약을 컴포넌트 API에 내장해야 합니다. 작성자가 라벨을 넘기는 행위 자체가 의도 선언이 됩니다.

false가 아니라 undefined인 것에도 명확한 이유가 있습니다.

<!-- 숨김 -->
<span aria-hidden="true"></span>

<!-- 명시적으로 숨기지 않음 — 조상이 hidden이어도 강제 노출 -->
<span aria-hidden="false"></span>

<!-- 속성을 안 붙임 — 기본 동작 -->
<span></span>

false를 쓰면 부모가 의도적으로 영역을 숨겨도 이 dot만 튀어나옵니다. 부모의 정책을 존중하려면 속성 자체를 안 다는 것(undefined → 미렌더)이 올바른 선택입니다.

사용처aria-labelaria-hidden스크린리더
텍스트 옆 장식없음true무시
단독 의미 전달있음없음라벨 읽음

접근성은 "나중에 챙기는 항목"이 아니라 컴포넌트의 API에 녹여서 호출부가 자연스럽게 올바른 결과로 가게 만드는 것이 옳습니다. 디자인 시스템 작성자가 한 번 결정해두면 호출부 전체가 자동으로 따라옵니다.

4. transparent는 색이지 "없음"이 아니다

버튼처럼 인터랙티브한 컴포넌트의 베이스 스타일은 거의 항상 background: transparent로 시작해야 합니다.

.button {
  display: inline-flex;
  background: transparent;
  cursor: pointer;
  color: var(--color-text);
  transition: background-color 120ms cubic-bezier(0.4, 0, 1, 1);
}

.button--ghost:active {
  background: rgba(0, 0, 0, 0.08);
}

CSS에서 transparent는 단순히 "투명함"이라는 키워드가 아닙니다. <color> 타입의 정식 색상값이고, 정확히 rgba(0, 0, 0, 0)과 동일합니다.

이 구분이 중요한 이유는 transition입니다. CSS transition은 같은 타입의 값 사이에서만 보간(interpolate)됩니다. background-color를 부드럽게 트랜지션하려면 시작과 끝 모두 색상값이어야 합니다.

베이스에 background를 지정하지 않거나 none 같은 값을 쓰면, 시작값이 색상값이 아니라서 트랜지션이 점프 형태로 일어납니다. transparent는 알파가 0인 색상이므로 rgba(0,0,0,0) → rgba(...)로 자연스러운 페이드가 가능합니다.

비슷해 보이는 값들의 차이를 알아두어야 합니다.

background: transparent;   /* rgba(0,0,0,0) — 색상값 */
background: none;          /* background-image에만 유효, color는 무시됨 */
background-color: initial; /* CSS 명세의 속성 기본값으로 리셋 */
background-color: unset;   /* 상속 가능하면 부모값, 아니면 initial */

transparent는 인터랙티브 컴포넌트에서 동시에 세 가지 역할을 합니다.

  1. <button>의 user-agent 회색 배경 리셋
  2. transition을 위한 valid color value
  3. ghost tone(배경 없는 버튼)의 기본값

한 단어가 세 역할을 하지만, 각 역할이 작동하는 원리를 모르면 의도대로 쓸 수 없습니다. 누군가 "어차피 안 보이는데 왜 굳이?"라고 묻는다면 위 세 가지로 답할 수 있어야 합니다.

5. inherit로 user-agent stylesheet 길들이기

폼 요소의 베이스에는 다음 두 줄이 거의 필수입니다.

button,
input,
select,
textarea {
  font-family: inherit;
  letter-spacing: inherit;
}

font-family, color 같은 텍스트 속성은 원래 부모로부터 자동 상속됩니다. 그러나 <button>, <input>, <select> 같은 폼 요소는 예외입니다. 브라우저가 user-agent stylesheet에서 자체 폰트를 강제로 박아둡니다.

/* 브라우저 기본 (예시) */
button {
  font-family: -apple-system, BlinkMacSystemFont, ...;
  font-size: 13.333px;
}

페이지 전체에 Pretendard를 깔아도 버튼만 시스템 폰트로 튀어 보이는 이유가 이것입니다. inherit은 user-agent 기본값을 무력화하고 부모(또는 :root)의 값을 다시 받아오게 합니다.

CSS-wide keyword는 네 개가 있고, 비슷해 보이지만 동작이 다릅니다.

font-family: inherit;  /* 부모의 계산된 값 */
font-family: initial;  /* CSS 명세 기본값 (보통 serif) — 원하는 동작 아님 */
font-family: revert;   /* user-agent 스타일로 복원 — 시스템 폰트 다시 등장 */
font-family: unset;    /* 상속 가능하면 inherit, 아니면 initial */

unset도 같은 효과를 내지만, "부모를 따라가겠다"는 의도를 명시적으로 선언하기 위해 inherit을 써야 합니다. 디자인 시스템에서 폼 요소를 길들이는 정석 패턴이고, 이 두 줄만으로 버튼·인풋·선택 박스가 페이지 타이포그래피와 일관되게 동작합니다.

6. min-width: 0 — flex item의 implicit min-content

flex 부모 안에 들어가는 컴포넌트의 베이스에는 min-width: 0이 거의 항상 필요합니다.

.button {
  display: inline-flex;
  min-width: 0;
}

CSS Flexbox L1 §4.5에 따르면 flex item의 min-width 초기값은 0이 아니라 auto입니다. 그리고 automin-content size — 콘텐츠가 줄바꿈 없이 들어갈 최소 너비로 계산됩니다.

/* 보이지 않는 기본값 */
.flex-item {
  min-width: auto;  /* = 콘텐츠가 안 잘리는 최소 너비 */
}

결과적으로 flex item은 콘텐츠 너비보다 작아질 수 없습니다. flex-shrink: 1이 있어도 무시되는 것처럼 보입니다.

<!-- min-width 기본값 (auto) -->
<div style="display: flex; width: 200px">
  <button>매우 매우 매우 긴 텍스트입니다</button>
</div>
<!-- 결과: 버튼이 400px 차지, 부모 200px 무시, 옆구리로 튀어나옴 -->

<!-- min-width: 0 명시 -->
<div style="display: flex; width: 200px">
  <button style="min-width: 0">매우 매우 매우 긴 텍스트입니다</button>
</div>
<!-- 결과: 버튼이 200px로 줄어듦, 내부 텍스트는 ellipsis 대상이 됨 -->

여기서 분리해서 기억해야 할 것이 있습니다. min-width: 0"줄어들 수 있게 만드는 단계"까지만 담당합니다. 실제로 잘리거나 점점점이 되는 건 별도의 속성이 처리합니다.

1단계: 줄어들 수 있게      → min-width: 0
2단계: 줄어든 후 처리 방식
       ├ overflow: hidden        → 그냥 잘림
       ├ text-overflow: ellipsis → 점점점 ...
       └ overflow: visible (기본) → 부모 밖으로 비집고 나옴

두 단계가 분업합니다. 모바일 webview처럼 화면이 좁고 가변적인 환경에서는 이 함정이 특히 자주 터집니다. 데스크탑에선 여백이 많아서 안 보이다가 작은 화면에서만 깨지는 케이스가 대표적입니다.

디자인 시스템 컴포넌트가 미리 min-width: 0을 베이스에 깔아두면 호출부가 이 함정을 신경 쓰지 않아도 됩니다. 어떤 flex 부모에 들어가든 깨지지 않는 안전장치를 컴포넌트 자체가 내장하는 것이 옳습니다.

7. 비대칭 transition — 모바일 네이티브 같은 터치감

마지막은 transition 디자인입니다. 다음 한 블록이 모바일 네이티브 같은 터치감을 만드는 핵심입니다.

:root {
  --duration-fast: 120ms;
  --easing-exit: cubic-bezier(0.4, 0, 1, 1);
}

.button {
  transition:
    background-color var(--duration-fast) var(--easing-exit),
    transform        var(--duration-fast) var(--easing-exit),
    color            var(--duration-fast) var(--easing-exit);
}

.button:active {
  transition-duration: 0ms;
  transform: scale(0.96);
}

각 부분에 들어간 결정에는 모두 이유가 있습니다.

명시적 속성 나열 (not all)

all로 두면 width, height 같은 레이아웃 속성까지 트랜지션이 걸립니다. 의도치 않은 애니메이션이 발생하거나 성능이 저하될 수 있습니다. 변하는 속성만 정확히 선언하는 것이 디자인 시스템의 안전한 기본값입니다.

속성언제 변하는가
background-colortone 변경, :active 시 색 변화
transformscale(0.96) (눌렀을 때 살짝 작아짐)
color글자색 변경 (특히 disabled, tone 전환)

120ms — "즉각적"이라고 느껴지는 시간

:root {
  --duration-fast: 120ms;  /* 버튼/탭 같은 즉각 피드백 */
  --duration-base: 180ms;  /* 일반 UI 전환 */
  --duration-slow: 280ms;  /* 페이지/모달 */
}

100~150ms는 사용자가 "즉각적"이라고 느끼는 구간입니다(Jakob Nielsen의 응답성 가이드라인). 버튼 인터랙션은 무조건 이 구간에 들어가야 "딱딱 반응한다"는 느낌이 납니다.

exit easing — 사용자 액션의 응답

:root {
  --easing-standard: cubic-bezier(0.2, 0.8, 0.2, 1);  /* 일반 */
  --easing-enter:    cubic-bezier(0, 0, 0.2, 1);      /* 등장 (decelerate) */
  --easing-exit:     cubic-bezier(0.4, 0, 1, 1);      /* 퇴장 (accelerate) */
}

Material Design 모션 시스템에서 정립된 패턴입니다.

enter (천천히 시작 → 빠르게 끝):   ___---‾‾‾   새 요소 등장 시
exit  (빠르게 시작 → 점점 느려짐): ‾‾‾---___   사용자 액션의 결과

버튼 press 피드백은 사용자가 트리거한 후의 응답이므로 exit easing이 맞습니다. 시작은 빠르게 반응하고 끝부분이 자연스럽게 가라앉습니다.

비대칭 트랜지션: :active에서 transition-duration: 0ms

이 두 줄이 핵심입니다. 누르는 순간에는 트랜지션을 끄는 트릭입니다.

.button {
  transition: ... 120ms var(--easing-exit);
}
.button:active {
  transition-duration: 0ms;  /* 누르는 동안만 트랜지션 비활성 */
  transform: scale(0.96);
}
0ms 트릭 없이:
  손가락 터치 → 120ms 천천히 색 변함 → "어, 안 눌렸나?" 느낌
  손가락 뗌  → 120ms 천천히 원복

0ms 트릭 적용:
  손가락 터치 → 즉시 색·스케일 변함 ("딸깍!")
  손가락 뗌  → 120ms 부드럽게 원복

즉, 누를 때는 즉각 반응 / 뗄 때는 부드럽게라는 비대칭 인터랙션을 만듭니다. iOS, Android 네이티브 버튼이 정확히 이렇게 동작합니다. 트랜지션을 양방향에 똑같이 거는 흔한 구현과 결정적으로 다른 지점입니다. webview에서 네이티브 같은 터치감을 만들려면 이 패턴은 거의 필수입니다.

설정효과
background-color, transform, color 명시의도한 속성만 부드럽게, 레이아웃 보호
120ms모바일 터치에 즉각적인 응답
--easing-exit사용자 액션의 응답에 맞는 감속 곡선
:activetransition-duration: 0ms"딸깍" 즉시 반응 + 자연스러운 복귀

마치며

여기서 다룬 일곱 가지 패턴은 모두 시각적 결과를 만드는 여러 방법 중 하나를 골라야 한다는 공통점이 있습니다. margin-left 대신 margin-inline-start, inline-flex 대신 inline-block, false 대신 undefined, 양방향 transition 대신 비대칭 transition — 결과는 비슷하거나 같지만, 코드가 담는 의도는 전혀 다릅니다.

CSS를 의미있게 쓴다는 것은 시안과 똑같이 그리는 능력이 아니라, 같은 결과를 내는 표현 중에서 "이 코드를 다시 볼 사람이 무엇을 알기를 원하는가"를 골라내는 능력입니다. 디자인 시스템에서는 특히 중요합니다. 컴포넌트 한 번의 결정이 호출부 수십 곳에 영향을 주고, 잘못된 기본값은 시간이 지날수록 비용이 커집니다.

코드가 곧 문서가 되는 자리에서는 CSS 한 단어가 명세를 대신 말해줍니다. inherit은 "user-agent를 무시한다"를 말하고, transparent는 "트랜지션 가능한 무색"을 말하고, margin-inline-start는 "i18n을 고려한 들여쓰기"를 말합니다. 의도가 코드에서 읽히는 순간, 리뷰는 짧아지고 회귀는 줄어듭니다. 디자인 시스템을 짜는 사람이 책임져야 할 영역입니다.

참고