04 — Custom Properties (CSS Variables)
CSS Custom Property는 “—로 시작하는 임의 속성” 처럼 보이지만, 본질은 DOM 트리를 따라 상속되는 런타임 토큰이다.
@property로 타입·기본값·상속 가능 여부를 선언하면 트랜지션·연산·color-mix같은 모던 기능이 통째로 열린다.
Why — 왜 CSS 변수가 디자인 시스템의 뼈대가 되었나
Sass/Less의 변수는 빌드 타임에 사라진다 — 컴파일 결과는 그냥 리터럴 값이다. CSS Custom Property는 런타임에 살아 있다:
- 다크 모드 토글 → 변수 하나만 바꾸면 페이지 전체 색이 바뀐다.
- 미디어/컨테이너 쿼리 안에서 변수 재정의 → 반응형 토큰.
- JS에서
element.style.setProperty('--x', val)→ 실시간 인터랙션. - React/Vue의 props를 CSS로 통째로 넘기는 패턴 — “styling pipe”.
그리고 결정적으로 — 상속. :root에 정의한 변수는 모든 자식에서 var(--x)로 읽을 수 있다. 이게 CSS-in-JS의 런타임 theming을 거의 무료로 만들어준다.
How — 변수가 어떻게 동작하나
1) 정의와 사용
:root {
--color-primary: #3370b8;
--space-2: 8px;
}
.button {
background: var(--color-primary);
padding: var(--space-2);
}규칙:
- 이름은
--로 시작. 대소문자 구분. - 값은 거의 임의의 토큰 — 색·길이·문자열·심지어 함수 호출도 가능.
- 자동으로 상속된다 (자식이
inherit없이도 받음). - 정의되지 않은 변수를
var()로 읽으면 → 두 번째 인자가 fallback.
color: var(--text, #333);
/* --text가 없으면 #333 */2) 스코프 — 상속을 따라간다
:root { --gap: 16px; }
.dense { --gap: 8px; } /* 이 트리 아래에서만 8px */
.flex { gap: var(--gap); }.dense > .flex는 gap: 8px, 다른 .flex는 gap: 16px. 요소 스코프 변수는 CSS-in-JS의 props 패턴을 CSS 안에서 표현한다.
3) 미디어/컨테이너 쿼리로 변수를 재정의
:root { --container: 1200px; }
@media (max-width: 768px) {
:root { --container: 100%; }
}
.wrap { max-width: var(--container); }토큰 한 곳, 분기 한 곳. 컴포넌트는 변경 없음.
4) @property — 타입 있는 변수 (CSS Houdini, 2024 Baseline)
@property --tilt {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.card {
transform: rotate(var(--tilt));
transition: --tilt 0.3s;
}
.card:hover { --tilt: 5deg; }@property 없이 --tilt를 트랜지션하면 동작하지 않는다 — CSS는 그 변수의 타입을 모르기 때문에 보간(interpolation)을 할 수 없다. @property로 <angle> 타입을 선언하면 그라데이션·색·각도·길이까지 트랜지션 가능.
@property 필드 | 의미 |
|---|---|
syntax | 값의 타입 (<length>, <color>, <angle>, <percentage>, *, '<length> | <percentage>' 등) |
inherits | 자식이 상속받을지 (true/false) |
initial-value | 기본값 (필수, syntax: '*'만 예외) |
syntax: '*'는 타입 없음 (그냥 토큰) — 트랜지션·연산 불가.
5) JS와의 연결
const root = document.documentElement;
root.style.setProperty('--theme', 'dark');
const val = getComputedStyle(root).getPropertyValue('--color-primary');이 한 줄로 React state → CSS 변수 → 페이지 전체 스타일 변경이 일어난다. 그리고 React 재렌더링은 필요 없다 — 브라우저 합성기만 작동.
What — 실무 패턴
1) :root 전역 토큰 + 컴포넌트 스코프 변수
/* 1층: 글로벌 디자인 토큰 */
:root {
--color-bg: #fff;
--color-fg: #1a1a1a;
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--radius: 8px;
}
/* 2층: 컴포넌트 의미론적 변수 */
.button {
--button-bg: var(--color-fg);
--button-fg: var(--color-bg);
--button-pad-x: var(--space-3);
background: var(--button-bg);
color: var(--button-fg);
padding: var(--space-2) var(--button-pad-x);
border-radius: var(--radius);
}
.button.primary { --button-bg: #3370b8; }
.button.dense { --button-pad-x: var(--space-2); }2층 구조의 핵심: variant마다 컴포넌트 변수만 바꾸면 된다. CSS는 변경 없음.
2) 다크 모드
:root {
--bg: #fff;
--fg: #1a1a1a;
}
:root[data-theme="dark"] {
--bg: #1a1a1a;
--fg: #fff;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #1a1a1a;
--fg: #fff;
}
}
body { background: var(--bg); color: var(--fg); }data-theme 속성 하나 토글로 전체 사이트 색 교체. 200개 색을 컴포넌트마다 작성하던 시대의 끝.
3) Open Props 패턴
Open Props는 “디자인 토큰 = 단순한 CSS 변수 패키지” 라는 철학을 극단까지 밀고 간 라이브러리.
@import "https://unpkg.com/open-props";
.card {
padding: var(--size-4);
border-radius: var(--radius-3);
box-shadow: var(--shadow-3);
}빌드 도구 없이, JS 없이, 그냥 CSS 변수.
4) React props → CSS 변수
<div style={{ '--tilt': `${angle}deg` }} className="card" />React state를 CSS로 파이프. 컴포넌트는 변경 없이, 변수만 갱신.
5) color-mix() + 변수
.button { background: var(--color-primary); }
.button:hover {
background: color-mix(in oklch, var(--color-primary), white 10%);
}변수 하나에서 hover/active/disabled를 수식으로 파생. Hex 색을 50개 정의하던 시대의 끝.
What-if — 잘못 쓰면
1) 폴백 없이 변수만 쓰기
.button { color: var(--text-color); }
/* --text-color가 어딘가에서 안 정의되면 → 색이 inherit 또는 initial */해결: var(--text-color, #333) — 항상 안전한 폴백.
2) @property 없이 트랜지션
.card { --opacity: 0; transition: --opacity 0.3s; }
.card:hover { --opacity: 1; }
/* 동작 안 함 — 타입 모름 */해결: @property --opacity { syntax: '<number>'; inherits: false; initial-value: 0; }
3) 변수 이름 충돌
--bg, --color, --size 같은 짧은 이름은 컴포넌트 변수 vs 전역 변수 충돌을 일으킨다. 네임스페이스 권장:
--btn-bg /* 컴포넌트 prefix */
--color-bg-page /* 의미 prefix */4) 비싼 변수 — 일부 속성의 reflow 트리거
--width로 width를 갱신하면 layout reflow가 일어난다. 변수 자체는 무료지만, 어떤 속성에 쓰는가가 비용을 결정한다. 애니메이션은 transform/opacity에 변수를 두는 게 좋다.
5) getComputedStyle().getPropertyValue()의 공백
const v = getComputedStyle(el).getPropertyValue('--color');
// " #3370b8" — 앞에 공백이 들어올 수 있음
const clean = v.trim();6) 캐스케이드를 통한 연쇄 상속의 디버깅 어려움
변수가 어디서 정의되었는지 DevTools에서 추적하기 어렵다. Chrome DevTools의 Computed 탭에서 --var를 클릭하면 정의 위치로 점프 — 이걸 알면 디버깅 시간이 반으로 준다.
Insight — @property는 CSS를 “프로그래밍 언어”로 만든 마지막 퍼즐
2016년 — Houdini Task Force가
CSS Properties and Values API를 제안했다. 핵심 아이디어: “CSS 변수에 타입을 주자.”그게 왜 중요한가? 타입이 있어야 보간(interpolation) 이 가능하다.
--opacity가<number>임을 브라우저가 알면0과1사이를 매끈하게 트랜지션할 수 있다. 타입을 모르면 그냥 문자열이라 0에서 1로 점프할 뿐.
@property는 2021년 Chrome, 2024년 Safari 17에 들어가며 Baseline에 합류했다. 이게 가능해지자 — 그라데이션 트랜지션, conic gradient 회전, 색 보간이 CSS만으로 가능해졌다.
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.glow {
background: conic-gradient(from var(--angle), red, blue, red);
transition: --angle 2s linear infinite;
}
.glow:hover { --angle: 360deg; }이 5줄로 회전하는 그라데이션을 만든다. JS 없이.
또 하나의 흥미로운 발전 — CSS 변수는 Sass를 사실상 폐기시켰다. 2015~2020년의 Sass는 변수, 믹스인, 함수, 중첩의 4가지를 제공했다. 이제:
- 변수 → CSS Custom Property (런타임)
- 믹스인 →
@layer+ utility 클래스 (Tailwind) - 함수 →
calc(),min(),clamp(),color-mix() - 중첩 → CSS Nesting (2023 Baseline)
Sass의 4가지가 모두 CSS 본체로 흡수되었다. 2025년 신규 프로젝트에서 Sass를 도입할 이유는 거의 사라졌다 — 이게 CSS Custom Property가 시작한 흐름이다.
요약 + Mermaid
- CSS 변수는 DOM 상속 + 런타임 — Sass 변수와 본질이 다름.
:root전역 토큰 + 컴포넌트 스코프 변수의 2층 구조가 표준.@property로 타입·기본값·상속 선언 → 트랜지션·연산 가능.var(--x, fallback)으로 항상 안전한 폴백.- JS
setProperty()로 React state → CSS pipe. - 다크 모드는 변수 토글 1줄로.