CSS @property 완전 가이드 — 타입 안전 디자인 토큰 선언과 CSS 변수 애니메이션(`css @property animation`, `design tokens type safety`) 구현까지
디자인 시스템 작업을 하다 보면 한 번쯤은 이런 상황을 마주치게 됩니다. --color-primary에 #3b82f6이 들어가야 할 자리에 오타로 #3b82f6px를 넣었는데, 브라우저가 아무 말 없이 그냥 넘어갑니다. 에러도 없고, 경고도 없고, 화면만 살짝 이상해지죠. 저도 처음엔 몇 분씩 원인을 찾아 헤맸는데, 알고 보면 기존 CSS 변수가 단순한 텍스트 치환에 불과하기 때문입니다. 브라우저 입장에서는 그게 색상인지, 숫자인지, 아니면 의미 없는 문자열인지 전혀 알 수 없거든요.
이 근본적인 한계를 해결해 주는 게 바로 @property입니다. CSS Houdini API(브라우저의 CSS 렌더링 엔진을 JavaScript로 확장하는 저수준 API 모음)의 일부인 이 기능은 커스텀 프로퍼티에 타입, 초기값, 상속 동작을 명시적으로 선언하게 해줍니다. @property를 활용하면 CSS 변수에 타입 안전성을 부여하고, 기존 CSS로는 구현 불가능했던 그라디언트 색상 전환 같은 애니메이션을 순수 CSS만으로 만들 수 있으며, Figma에서 CSS까지 이어지는 디자인 토큰 파이프라인을 타입 정보가 끊기지 않는 구조로 만들 수 있습니다. 현재 Chrome, Firefox, Safari, Edge 모두에서 동작하며 전 세계 브라우저 사용률 기준 약 93% 이상을 커버합니다.
핵심 개념
CSS 변수의 근본적 한계와 @property의 등장
기존 CSS 커스텀 프로퍼티가 어떻게 동작하는지부터 짚어보겠습니다.
:root {
--color-primary: #3b82f6;
}
.button {
/* 브라우저 입장에서는 "#3b82f6"이라는 문자열을 붙여넣는 것과 같음 */
background-color: var(--color-primary);
}브라우저는 --color-primary의 값이 색상인지 길이인지 모릅니다. 그냥 문자열입니다. 그래서 두 값 사이를 부드럽게 이어주는 보간(interpolation)이 불가능하고, 잘못된 값이 들어와도 조용히 무시해 버립니다.
@property는 이 문제를 정면으로 해결합니다.
@property --color-primary {
syntax: "<color>";
inherits: true;
initial-value: hsl(217 91% 60%);
}세 줄만 추가하면 브라우저가 이 변수를 색상으로 인식합니다. 이제 트랜지션도 되고, 잘못된 값이 들어오면 initial-value로 폴백됩니다.
세 가지 필수 디스크립터
@property 선언에는 세 가지 디스크립터가 모두 필수입니다. 하나라도 빠지면 전체 선언이 무효 처리됩니다.
| 디스크립터 | 역할 | 예시 |
|---|---|---|
syntax |
값의 타입 지정 | "<color>", "<length>", "<number>", "<angle>", "*" |
inherits |
부모로부터 값 상속 여부 | true / false |
initial-value |
타입 불일치 시 폴백값 | 해당 타입에 맞는 유효한 절대값 |
syntax 목록에 "*"도 있는데, 이건 "어떤 값이든 받는다"는 의미입니다. 타입 보간은 포기하지만 initial-value 폴백과 inherits 격리는 사용하고 싶을 때, 또는 calc(var(--a) + var(--b)) 같은 동적 조합값처럼 특정 타입으로 고정하기 어려운 경우에 씁니다.
@property --opacity-overlay {
syntax: "<number>";
inherits: false; /* 자식 요소에 전파 안 함 */
initial-value: 0;
}JavaScript에서의 동등한 방법
CSS at-rule 대신 JavaScript로도 동일한 등록이 가능합니다.
CSS.registerProperty({
name: '--color-primary',
syntax: '<color>',
inherits: true,
initialValue: 'hsl(217 91% 60%)',
});두 방법은 기능상 동등하지만 차이가 있습니다. @property at-rule은 스타일시트 파싱 시점에 등록되어 JavaScript 실행 전에 준비됩니다. 실제로 테마 전환 기능을 붙일 때 CSS.registerProperty()를 활용한 적이 있는데, 사용자 설정에 따라 런타임에 다른 초기값으로 등록해야 하는 상황이라면 이쪽이 훨씬 유연합니다. A/B 테스트나 동적 테마 등록에는 JavaScript 방식을, 정적 토큰 선언에는 at-rule을 쓰는 것을 권장합니다.
@layer tokens와의 조합 — 우선순위 명시적 관리
디자인 토큰을 별도 레이어로 분리하면 우선순위 관리가 명확해집니다. @property 선언은 @layer 안에 그대로 넣을 수 있습니다.
한 가지 역직관적인 점이 있습니다. CSS Cascade Layers에서는 먼저 선언된 레이어가 낮은 우선순위를 가집니다. 즉, @layer tokens, base, components, utilities 순서로 선언하면 tokens가 가장 낮은 우선순위, utilities가 가장 높은 우선순위가 됩니다. 디자인 토큰에게는 딱 맞는 구조입니다. 토큰은 기본값을 제공하고, 컴포넌트나 유틸리티가 그 위에서 자유롭게 덮어쓸 수 있으니까요.
@layer tokens, base, components, utilities;
@layer tokens {
@property --color-primary {
syntax: "<color>";
inherits: true;
initial-value: hsl(217 91% 60%);
}
@property --spacing-base {
syntax: "<length>";
inherits: true;
initial-value: 4px;
}
@property --radius-md {
syntax: "<length>";
inherits: true;
initial-value: 8px;
}
}실전 적용
예시 1: 타입 안전 디자인 토큰 선언
실무에서 가장 먼저 적용해볼 수 있는 패턴입니다. 기존에 :root에 쌓아두던 CSS 변수들을 @property와 함께 토큰 레이어로 이전하는 방식입니다.
@layer tokens {
/* 색상 토큰 */
@property --color-primary {
syntax: "<color>";
inherits: true;
initial-value: hsl(217 91% 60%);
}
@property --color-surface {
syntax: "<color>";
inherits: true;
initial-value: hsl(0 0% 100%);
}
/* 간격 토큰 */
@property --spacing-base {
syntax: "<length>";
inherits: true;
initial-value: 4px;
}
/* 모서리 반경 토큰 */
@property --radius-md {
syntax: "<length>";
inherits: true;
initial-value: 8px;
}
/* 오버레이 불투명도 — 컴포넌트 로컬, 상속 차단 */
@property --opacity-overlay {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
}
@layer components {
.button {
background-color: var(--color-primary);
border-radius: var(--radius-md);
padding: var(--spacing-base) calc(var(--spacing-base) * 4);
color: var(--color-surface);
}
}여기서 핵심은 inherits: false로 선언한 --opacity-overlay입니다. 이 토큰은 자식 요소로 전파되지 않기 때문에, 모달이나 드로어 같은 컴포넌트 내부에서만 동작하는 값으로 격리됩니다. 디자인 시스템에서 의도치 않은 값 전파로 고생해본 분이라면 이 기능이 얼마나 반가운지 아실 겁니다.
| 포인트 | 설명 |
|---|---|
initial-value 폴백 |
--color-primary에 "red-ish" 같은 비색상값을 넣으면 hsl(217 91% 60%)으로 자동 폴백 |
| 상속 격리 | inherits: false로 컴포넌트 스코프 내 값 전파 방지 |
| 타입 문서화 | 스타일시트 자체가 타입 정보를 담아 미래 도구의 자동완성 기반 제공 |
예시 2: 그라디언트 색상 전환 애니메이션
솔직히 이게 @property의 가장 인상적인 활용 사례입니다. 기존 CSS 변수로는 그라디언트 색상 자체를 트랜지션하는 게 불가능했거든요. 처음 이 코드가 실제로 동작하는 걸 봤을 때 "왜 이걸 이제야 알았지" 싶었습니다.
@layer tokens {
@property --gradient-start {
syntax: "<color>";
inherits: false;
initial-value: #3b82f6;
}
@property --gradient-end {
syntax: "<color>";
inherits: false;
initial-value: #8b5cf6;
}
}
@layer components {
.card {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
transition:
--gradient-start 0.4s ease,
--gradient-end 0.4s ease;
}
.card:hover {
--gradient-start: #ef4444;
--gradient-end: #f97316;
}
}<color> 타입이 등록되어 있기 때문에, 브라우저가 파란색→빨간색 사이의 중간 색상을 프레임마다 계산해 부드러운 그라디언트 전환을 만들어냅니다. 등록되지 않은 CSS 변수였다면 transition을 걸어도 그냥 확 바뀌었을 겁니다. 타입 정보가 없으면 브라우저가 무엇을 보간해야 할지 알 수 없으니까요.
예시 3: conic-gradient 기반 진행 원호 — <angle> 타입의 진가
<angle> 타입의 장점이 가장 두드러지는 패턴입니다. conic-gradient와 조합하면 JavaScript로 각도를 제어하면서도 CSS 트랜지션으로 부드러운 애니메이션을 얻을 수 있습니다. 여기서 initial-value: 0deg가 애니메이션의 암묵적인 시작점 역할을 합니다.
@layer tokens {
@property --arc-end {
syntax: "<angle>";
inherits: false;
initial-value: 0deg; /* @keyframes from이 생략될 때 이 값에서 시작 */
}
}
@layer components {
.progress-ring {
width: 80px;
height: 80px;
border-radius: 50%;
background: conic-gradient(
#3b82f6 var(--arc-end),
#e5e7eb 0
);
transition: --arc-end 0.5s ease-out;
}
}function setProgress(element, percent) {
const angle = percent * 3.6; // 100% → 360deg
element.style.setProperty('--arc-end', `${angle}deg`);
}
setProgress(document.querySelector('.progress-ring'), 75); // 270deg로 부드럽게 이동conic-gradient의 멈춤점을 CSS 변수로 지정하고, 그 변수가 <angle> 타입이라 JavaScript가 값을 바꿀 때마다 CSS transition이 자연스럽게 붙습니다. transform: rotate()를 직접 애니메이션하는 방식으로는 이 패턴을 구현할 수 없습니다.
예시 4: 숫자 기반 진행률 표시
@layer tokens {
@property --progress {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
}
@layer components {
.progress-bar {
position: relative;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
transition: --progress 0.6s ease-out;
}
.progress-bar::after {
content: "";
position: absolute;
inset: 0;
background: #3b82f6;
width: calc(var(--progress) * 1%);
border-radius: inherit;
}
}function setProgress(element, value) {
element.style.setProperty('--progress', value);
}
setProgress(document.querySelector('.progress-bar'), 75);<number> 타입으로 등록했기 때문에 transition: --progress 0.6s ease-out이 제대로 작동합니다. 0에서 75로 부드럽게 올라가는 진행률 바를 CSS와 JavaScript 코드 몇 줄만으로 만들 수 있습니다.
예시 5: Style Dictionary v4 + @property 자동 변환 파이프라인
디자인 시스템 규모가 커지면 수작업으로 @property를 선언하기 어려워집니다. Figma 또는 Tokens Studio(Figma 플러그인으로 디자인 토큰을 JSON으로 관리하는 도구)에서 뽑은 JSON을 CSS @property 선언으로 자동 변환하는 파이프라인을 살펴보겠습니다. Style Dictionary는 이런 토큰 변환을 자동화해 주는 빌드 도구입니다.
// tokens/color.json — W3C DTCG(W3C 디자인 토큰 커뮤니티 그룹 표준 포맷)
{
"color": {
"primary": {
"$value": "#3b82f6",
"$type": "color"
},
"surface": {
"$value": "#ffffff",
"$type": "color"
}
},
"spacing": {
"base": {
"$value": "4px",
"$type": "dimension"
}
}
}// style-dictionary.config.js
import StyleDictionary from 'style-dictionary';
const typeMap = {
color: '<color>',
dimension: '<length>',
number: '<number>',
fontWeight: '<number>',
};
StyleDictionary.registerFormat({
name: 'css/at-property',
format: ({ dictionary }) => {
const declarations = dictionary.allTokens.map(token => {
const syntax = typeMap[token.$type] ?? '*';
// Style Dictionary의 기본 name transform이 중첩 구조를 평탄화합니다:
// color.primary → color-primary
return `@property --${token.name} {
syntax: "${syntax}";
inherits: true;
initial-value: ${token.value};
}`;
});
return `@layer tokens {\n${declarations.join('\n\n')}\n}`;
},
});
const sd = new StyleDictionary({
source: ['tokens/**/*.json'],
platforms: {
css: {
transformGroup: 'css',
files: [
{
destination: 'dist/tokens.css',
format: 'css/at-property',
},
],
},
},
});
await sd.buildAllPlatforms();W3C DTCG 스펙의 $type 필드가 CSS @property의 syntax와 의미적으로 정확히 대응됩니다. token.name은 Style Dictionary의 기본 name transform이 JSON 중첩 구조(color.primary)를 CSS 변수명 형태(color-primary)로 자동 변환해 줍니다. 이 파이프라인을 구성해두면 Figma에서 토큰을 수정하는 순간부터 타입 정보가 보존된 채로 CSS까지 자동으로 흘러갑니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 타입 안전성 | 잘못된 타입의 값이 들어오면 initial-value로 폴백. 조용한 실패(silent failure)가 사라짐 |
| 애니메이션/트랜지션 | 타입이 선언된 프로퍼티는 CSS transition·animation에서 보간 가능. 그라디언트 색상 전환 등 구현 |
| 상속 제어 | inherits: false로 컴포넌트 로컬 프로퍼티의 자식 전파 차단. 스타일 격리 강화 |
| JS 의존성 없음 | @property at-rule은 스타일시트 파싱 시점에 등록되어 JavaScript 실행 전에 준비 |
| 타입 문서화 | 스타일시트 자체가 타입 정보를 포함해 미래 도구의 자동완성·타입 힌트 기반 제공 |
| 파이프라인 표준화 | W3C DTCG $type과 CSS syntax의 대응으로 디자인 도구→CSS 타입 보존 파이프라인 구축 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
initial-value 제약 |
다른 변수 참조, calc(), clamp() 등 동적 값 사용 불가 |
절대값만 사용. 상대 계산이 필요하면 * 타입으로 등록하거나 생략 |
| 재등록 불가 | 동일 이름으로 재등록 시 에러 발생 | 타입 변경이 필요한 경우 아예 다른 이름 사용 |
| 쿼리 조건 불가 | 컨테이너 쿼리나 미디어 쿼리 조건문으로 사용 불가 | 상태 관리는 data attribute 또는 클래스 기반으로 처리 |
| 메인 스레드 애니메이션 | transform·opacity와 달리 GPU 합성 레이어로 넘어가지 않음 |
색상·불투명도처럼 합성 단계에만 영향을 주는 속성 선택 권장 |
| 레거시 브라우저 | "Widely Available" 지정은 2027년 1월 예정 | JavaScript 기능 감지 후 점진적 향상 적용 |
메인 스레드 애니메이션에 대해 조금 더 짚고 넘어가겠습니다. transform이나 opacity는 GPU 합성 레이어에서 처리되어 메인 스레드를 건드리지 않습니다. 반면 커스텀 프로퍼티 애니메이션이 GPU 합성 레이어로 넘어가지 못하는 이유는, 브라우저가 해당 변수가 어떤 CSS 속성에 영향을 줄지 정적으로 알 수 없기 때문입니다. --color-primary가 background-color에만 쓰일지, border-color에도 쓰일지, 계산 전까지는 모르기 때문에 매 프레임 스타일 재계산이 메인 스레드에서 이루어집니다.
레거시 브라우저 폴백의 경우, @supports로 @property 지원 여부를 CSS 안에서 감지하는 표준적인 방법은 현재 존재하지 않습니다. syntax는 CSS 프로퍼티가 아니라 @property at-rule의 디스크립터이기 때문에 @supports (syntax: "<color>") 같은 쿼리는 올바르게 동작하지 않습니다. 대신 두 가지 방법을 권장합니다.
방법 1: JavaScript 기능 감지 후 클래스 추가
if (typeof CSS !== 'undefined' && typeof CSS.registerProperty === 'function') {
document.documentElement.classList.add('supports-at-property');
}/* 기본 정적 스타일 — 모든 브라우저에서 동작 */
.card {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
}
/* @property 지원 브라우저용 향상 */
.supports-at-property .card {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
transition: --gradient-start 0.4s ease, --gradient-end 0.4s ease;
}방법 2: 점진적 향상으로 구조 설계
애니메이션 효과가 없어도 기본 스타일이 완전히 동작하도록 CSS를 작성하고, @property 기반 향상은 자연스럽게 그 위를 덮는 구조입니다.
/* 항상 동작하는 기본 스타일 */
.card {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
}
/* @property가 지원되면 변수 기반 동적 그라디언트로 덮어씌워짐 */
@layer tokens {
@property --gradient-start {
syntax: "<color>";
inherits: false;
initial-value: #3b82f6;
}
@property --gradient-end {
syntax: "<color>";
inherits: false;
initial-value: #8b5cf6;
}
}
@layer components {
.card {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
transition: --gradient-start 0.4s ease, --gradient-end 0.4s ease;
}
}실무에서 가장 흔한 실수
-
initial-value에 동적 값 사용 시도 —initial-value: calc(var(--base) * 2)처럼 다른 변수를 참조하거나 계산식을 넣으면 전체@property선언이 무효 처리됩니다. 초기값은 반드시 계산상 독립적인 절대값이어야 합니다. -
inherits디스크립터 생략 —syntax와initial-value만 쓰고inherits를 빠뜨리면 선언 전체가 무효입니다. 세 가지 디스크립터가 모두 있어야 합니다. -
@supports로@property지원 감지 시도 —@supports (syntax: "<color>")는syntax가 CSS 프로퍼티가 아니기 때문에 작동하지 않습니다. 지원 감지는 JavaScript(typeof CSS.registerProperty === 'function')로 해야 합니다.
마치며
@property는 CSS 변수를 단순한 텍스트 치환에서 타입 있는 디자인 토큰으로 격상시킵니다. 타입 안전성, 보간 가능한 애니메이션, 상속 격리 — 이 세 가지만으로도 도입 이유는 충분합니다.
지금 바로 시작해볼 수 있는 3단계:
-
기존
:rootCSS 변수 중 색상 토큰 하나를 골라@property로 재선언해보시면 됩니다.syntax: "<color>",inherits: true,initial-value: [기존값]세 줄만 추가한 뒤, 해당 변수에transition을 걸고 호버 시 색상을 바꾸면 보간이 동작하는 것을 바로 확인할 수 있습니다. -
@layer tokens를 도입해@property선언을 한 곳에 모아보시면 좋습니다.@layer tokens, base, components, utilities;선언으로 레이어 순서를 정하고, 기존 토큰들을tokens레이어로 이전하면 우선순위 관리가 눈에 띄게 명확해집니다. -
Style Dictionary v4를 사용 중이라면 위 예시의 커스텀 포맷터를 적용해 보시면 됩니다. W3C DTCG 포맷(
$type필드)으로 토큰 JSON을 관리하고 있다면, 포맷터 하나로@propertyCSS 출력을 자동화해 Figma부터 CSS까지 타입 정보가 끊기지 않는 파이프라인을 구성할 수 있습니다.
도입한 뒤 자연스럽게 생기는 다음 질문도 있습니다. Chromium DevTools의 Styles 패널에서 등록된 @property의 타입 정보와 computed value를 어떻게 확인하는지, 팀에 도입할 때 코드 리뷰 기준을 어떻게 잡을지 — initial-value에 절대값만 허용하고 inherits 선언을 필수로 만드는 Stylelint 룰을 팀 표준으로 잡아두면 일관성을 유지하기 수월합니다. 이런 실무 도입 이야기는 다음 글에서 이어가겠습니다.
다음 글: CSS Typed OM(Typed Object Model)을 활용해 JavaScript에서 CSS 값을 타입 안전하게 읽고 쓰는 방법 —
element.attributeStyleMap과CSSUnitValue로 숫자 계산 오류 없애기
참고 자료
- @property: Next-gen CSS variables now with universal browser support | web.dev
- @property: giving superpowers to CSS variables | web.dev
- Benchmarking the performance of CSS @property | web.dev
- Smarter custom properties with Houdini's new API | web.dev
- @property CSS at-rule | MDN Web Docs
- CSS Properties and Values API | MDN Web Docs
- Providing Type Definitions for CSS with @property | Modern CSS Solutions
- Exploring @property and its Animating Powers | CSS-Tricks
- The gotcha with @property animating custom properties | Bram.us
- Design Tokens specification reaches first stable version | W3C DTCG
- Style Dictionary — DTCG Support
- Houdini APIs | MDN Web Docs
- CSS Properties and Values API Level 1 | W3C Spec
- Taking a closer look at @property in CSS | utilitybend