CSS 커스텀 프로퍼티 고급 패턴: 디자인 토큰부터 런타임 테마까지

프론트엔드

CSS커스텀 프로퍼티디자인 토큰다크모드CSS 고급

이 글은 누구를 위한 것인가

  • CSS 변수를 단순 색상 저장 이상으로 활용하고 싶은 개발자
  • @property로 타입 안전한 CSS 변수를 만들고 싶은 팀
  • 디자인 토큰을 CSS Custom Properties로 체계화하려는 팀

들어가며

var(--color-primary)를 사용하는 것은 시작일 뿐이다. @property로 타입과 초기값을 정의하고, 계층적 디자인 토큰 구조를 만들고, JS와 양방향으로 통신하면 CSS가 완전한 디자인 시스템의 기반이 된다.

이 글은 bluefoxdev.kr의 CSS Custom Properties 심화 가이드 를 참고하여 작성했습니다.


1. @property: 타입 안전한 CSS 변수

[@property 구문]
@property --property-name {
  syntax: '<color>';        /* 타입 지정 */
  inherits: false;          /* 상속 여부 */
  initial-value: #000000;  /* 초기값 (syntax 있으면 필수) */
}

[지원 syntax 타입]
  <number>     숫자
  <integer>    정수
  <length>     px, em, rem 등
  <percentage> 퍼센트
  <angle>      deg, rad, turn
  <color>      색상
  <image>      이미지
  <url>        URL
  <transform-list>  transform 함수 목록
  *            모든 값 (기본, 타입 검사 없음)

[@property의 장점]
  1. transition/animation 가능 (타입 알아야 보간 가능)
  2. 잘못된 값 무시 (타입 미스매치)
  3. 초기값으로 안전한 폴백

2. 고급 CSS Custom Properties 패턴

/* @property - 애니메이션 가능한 커스텀 프로퍼티 */
@property --hue {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

@property --progress {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

/* 색상 회전 애니메이션 */
.rainbow-button {
  --hue: 0deg;
  background: hsl(var(--hue) 70% 60%);
  transition: --hue 0.5s;
}

.rainbow-button:hover {
  --hue: 360deg;
}

/* 진행률 바 애니메이션 */
@property --bar-progress {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

.progress-bar {
  --bar-progress: 0%;
  background: linear-gradient(
    to right,
    var(--color-primary) var(--bar-progress),
    var(--color-surface) var(--bar-progress)
  );
  transition: --bar-progress 0.6s ease-out;
}

.progress-bar[data-progress="75"] {
  --bar-progress: 75%;
}
/* 디자인 토큰 계층 구조 */

/* 1. 원시 토큰 (절대 직접 사용하지 않음) */
:root {
  --primitive-blue-100: #dbeafe;
  --primitive-blue-500: #3b82f6;
  --primitive-blue-900: #1e3a8a;
  
  --primitive-gray-50:  #f9fafb;
  --primitive-gray-900: #111827;
  
  --primitive-space-1: 4px;
  --primitive-space-2: 8px;
  --primitive-space-4: 16px;
  --primitive-space-8: 32px;
}

/* 2. 시멘틱 토큰 (의미 기반, 실제 사용) */
:root {
  --color-primary:        var(--primitive-blue-500);
  --color-primary-hover:  var(--primitive-blue-900);
  --color-background:     var(--primitive-gray-50);
  --color-text:           var(--primitive-gray-900);
  --color-surface:        white;
  
  --space-component: var(--primitive-space-4);
  --space-section:   var(--primitive-space-8);
  
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 16px;
}

/* 3. 다크모드: 시멘틱 토큰만 재정의 */
[data-theme="dark"] {
  --color-primary:    var(--primitive-blue-100);
  --color-background: var(--primitive-gray-900);
  --color-text:       var(--primitive-gray-50);
  --color-surface:    #1f2937;
}

/* OS 다크모드 자동 감지 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-background: var(--primitive-gray-900);
    --color-text:       var(--primitive-gray-50);
    --color-surface:    #1f2937;
  }
}

/* 4. 컴포넌트 스코프 토큰 */
.button {
  /* 컴포넌트 내부 변수 (외부에서 오버라이드 가능) */
  --button-bg:          var(--color-primary);
  --button-text:        white;
  --button-radius:      var(--radius-md);
  --button-padding-x:   var(--primitive-space-4);
  --button-padding-y:   var(--primitive-space-2);
  
  background: var(--button-bg);
  color: var(--button-text);
  border-radius: var(--button-radius);
  padding: var(--button-padding-y) var(--button-padding-x);
}

/* 외부에서 버튼 변형 */
.button.danger {
  --button-bg: #ef4444;
}

.button.large {
  --button-padding-x: var(--primitive-space-8);
  --button-padding-y: var(--primitive-space-4);
}
// JavaScript ↔ CSS 양방향 통신

// CSS 변수 읽기
const style = getComputedStyle(document.documentElement);
const primaryColor = style.getPropertyValue('--color-primary').trim();
const space = style.getPropertyValue('--primitive-space-4').trim();

// CSS 변수 쓰기 (JS에서 테마 변경)
function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
}

// 사용자 선택 색상으로 브랜드 색상 동적 변경
function applyBrandColor(hsl) {
  const root = document.documentElement;
  root.style.setProperty('--color-primary', `hsl(${hsl})`);
}

// CSS 변수로 애니메이션 상태 전달
function animateProgress(element, targetPercent) {
  element.style.setProperty('--bar-progress', `${targetPercent}%`);
}

// IntersectionObserver와 CSS 변수 결합
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    // CSS 변수로 가시성 비율 전달
    entry.target.style.setProperty(
      '--visibility-ratio',
      entry.intersectionRatio.toString()
    );
  });
}, { threshold: Array.from({ length: 101 }, (_, i) => i / 100) });

document.querySelectorAll('.animate-on-scroll').forEach(el => observer.observe(el));
/* IntersectionObserver와 CSS 변수 결합 */
.animate-on-scroll {
  --visibility-ratio: 0;
  opacity: var(--visibility-ratio);
  transform: translateY(calc((1 - var(--visibility-ratio)) * 40px));
  transition: opacity 0.3s, transform 0.3s;
}

/* 반응형 CSS 변수 */
:root {
  --columns: 1;
  --font-size-base: 14px;
}

@media (min-width: 640px) {
  :root {
    --columns: 2;
    --font-size-base: 15px;
  }
}

@media (min-width: 1024px) {
  :root {
    --columns: 3;
    --font-size-base: 16px;
  }
}

.grid {
  display: grid;
  grid-template-columns: repeat(var(--columns), 1fr);
  font-size: var(--font-size-base);
}

마무리

CSS Custom Properties의 진정한 힘은 계층 구조와 동적 변경이다. 원시 토큰 → 시멘틱 토큰 → 컴포넌트 토큰의 3단계 구조는 전체 테마 변경을 시멘틱 토큰만 교체하는 것으로 해결한다. @property<angle>, <percentage> 같은 타입을 지정해 CSS 트랜지션이 커스텀 프로퍼티 값을 보간할 수 있게 한다. JS에서 setProperty()로 CSS 변수를 설정하면 CSS 전환 효과가 그대로 적용된다.