이 글은 누구를 위한 것인가
- 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 전환 효과가 그대로 적용된다.