🎨 Frontend CSS0. CSS의 기초04 — Custom Properties

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 > .flexgap: 8px, 다른 .flexgap: 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 트리거

--widthwidth를 갱신하면 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>임을 브라우저가 알면 01 사이를 매끈하게 트랜지션할 수 있다. 타입을 모르면 그냥 문자열이라 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줄로.