@property와 동적 스타일링
작성일:2025.05.13|수정일:2026.07.07|조회수:3

프론트엔드 개발자들이 모인 자리에서 “어떤 스타일링 도구 좋아하세요?”라고 물어보면 못해도 30분은 이야기할 수 있다. 내 경우에는 여러 번 말했듯이 Tailwind CSS와 CSS Module을 선호한다. 그런데 이 취향을 말하면 거의 정해진 질문이 돌아온다. “그거 동적 스타일링하기 불편하지 않아요?”
틀린 말은 아니다. Vanilla Extract나 CSS-in-JS 계열과 비교하면 Tailwind CSS와 CSS Module은 동적인 값에 약한 구석이 있다. 특히 Tailwind CSS는 클래스 이름을 정적으로 분석해서 필요한 CSS를 생성하는 도구이므로, 런타임에서 클래스 이름을 조립하는 패턴과 잘 맞지 않는다.
// 괜찮다. 가능한 클래스 이름이 코드에 그대로 드러난다.
<div className={isActive ? 'bg-blue-500' : 'bg-gray-300'} />
// 위험하다. 빌드 도구가 최종 클래스 이름을 안정적으로 알기 어렵다.
<div className={`bg-${color}`} />Tailwind CSS v4에서는 동적 유틸리티 값과 CSS 변수 기반 설계가 더 좋아졌지만, 그래도 “런타임에서 아무 문자열이나 조합해서 클래스 이름으로 쓰는 방식”이 좋은 패턴이 되는 것은 아니다. 이 문제는 Tailwind가 부족하다기보다, Tailwind가 애초에 정적 클래스 이름을 중심으로 설계된 도구이기 때문에 생기는 자연스러운 경계에 가깝다.
CSS 커스텀 속성
CSS Module 쪽은 CSS 커스텀 속성을 같이 쓰면 사정이 꽤 나아진다. 클래스 이름은 정적으로 유지하고, 런타임에 바뀌는 값만 커스텀 속성으로 흘려보내는 방식이다. 컴포넌트는 값만 넘기고, 실제 스타일 계산은 CSS가 맡는다.
import { useState } from 'react';
import styles from './Box.module.css';
export default function Page() {
const [size, setSize] = useState(100);
const style = {
'--box-size': `${size}px`,
} as React.CSSProperties;
return (
<div>
<div className={styles.box} style={style}>
박스 영역
</div>
<button type="button" onClick={() => setSize(size + 10)}>
사이즈 키우기
</button>
</div>
);
}/* Box.module.css */
.box {
width: var(--box-size);
height: var(--box-size);
display: flex;
align-items: center;
justify-content: center;
background-color: #eee;
}이 방식의 장점은 책임이 비교적 분명하다는 점이다. React는 100px인지 120px인지 같은 값만 전달하고, CSS는 그 값을 어떻게 사용할지 결정한다. 테마 색상, 간격, 애니메이션 지속 시간, 진행률처럼 값 자체가 런타임에 바뀌는 경우에는 이 정도가 가장 현실적인 타협이라고 생각한다.
물론 만능은 아니다. 커스텀 속성은 기본적으로 상속되고 캐스케이드에 참여하므로, 범위를 제대로 관리하지 않으면 어디서 값이 바뀌었는지 추적하기 어려워진다. 컴포넌트 단위로 쓴다면 이름에 접두사를 붙이거나, 값이 필요한 루트 요소에만 좁게 선언하는 습관이 필요하다.
@property
CSS 커스텀 속성은 편하지만, 그냥 선언한 --foo는 브라우저 입장에서 “대충 토큰 묶음”에 가깝다. 타입을 모른다. --progress: 10%인지 --progress: red인지 선언 자체만으로는 제한하지 않는다. 그래서 애니메이션이나 계산에서 다루려면 아쉬운 부분이 생긴다.
@property는 커스텀 속성에 타입, 초기값, 상속 여부를 부여하는 CSS 규칙이다. 이렇게 등록된 커스텀 속성은 브라우저가 값을 더 구체적으로 이해할 수 있고, 특히 숫자·길이·색상처럼 보간 가능한 타입은 애니메이션에서도 훨씬 자연스럽게 다룰 수 있다.
@property --progress {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
.progressBar {
--progress: 0%;
width: 240px;
height: 10px;
overflow: hidden;
border-radius: 999px;
background: #e5e7eb;
}
.progressBar::before {
display: block;
width: var(--progress);
height: 100%;
background: #2563eb;
transition: --progress 300ms ease;
content: '';
}
.progressBar[data-state='done'] {
--progress: 100%;
}여기서 중요한 부분은 transition: --progress 300ms ease;다. 등록하지 않은 커스텀 속성은 많은 경우 불연속적으로 바뀌지만, @property로 <percentage> 타입을 알려주면 브라우저가 중간 값을 계산할 수 있다. @property는 단순히 문법을 예쁘게 만드는 기능이라기보다, 커스텀 속성을 브라우저가 이해할 수 있는 값으로 등록하는 장치에 가깝다.
@property를 쓸 때는 몇 가지 규칙도 같이 기억해야 한다.
syntax와inherits는 필수다. 빠지면 규칙 전체가 무시된다.syntax가'*'가 아니라면initial-value도 필요하다.initial-value는 계산 독립적인 값이어야 한다. 예를 들어10px은 괜찮지만, 부모 글자 크기에 의존하는3em은 초기값으로 부적절할 수 있다.- 같은 이름의
@property를 여러 번 선언하면 스타일시트 순서상 뒤의 선언이 이긴다.
그리고 CSS Module 안에 작성하더라도 @property 자체는 로컬 스코프가 아니다. 클래스 이름처럼 해시가 붙는다고 생각하면 안 된다. 여러 컴포넌트에서 같은 --progress를 서로 다른 타입으로 등록하면 충돌할 수 있다. 내 경우에는 등록이 필요한 속성은 global.css 같은 전역 파일에 모아두거나, 최소한 --component-name-progress처럼 이름을 길게 가져가는 편이 마음이 편하다.
attr()
조금 더 욕심을 내면 HTML 속성 값을 CSS에서 직접 읽고 싶어진다. attr() 함수가 그 역할을 한다. 가장 오래되고 안정적인 사용처는 content다.
<button class="button" data-label="저장됨">저장</button>.button::after {
content: ' (' attr(data-label) ')';
}문제는 우리가 보통 원하는 것이 여기서 끝나지 않는다는 데 있다. data-size="120"을 읽어서 width로 쓰고, data-color="#2563eb"을 읽어서 배경색으로 쓰고 싶다. 최신 attr() 문법은 타입과 기본값을 표현할 수 있다.
.box {
width: attr(data-size px, 100px);
height: attr(data-size px, 100px);
background-color: attr(data-color type(<color>), #eee);
}문법만 보면 꽤 그럴듯하다. JSX 쪽도 깔끔하다.
export default function Page() {
const [size, setSize] = useState(100);
return (
<div>
<div className={styles.box} data-size={size} data-color="#2563eb">
박스 영역
</div>
<button type="button" onClick={() => setSize(size + 10)}>
사이즈 키우기
</button>
</div>
);
}하지만 여기서 조심해야 한다. MDN 기준으로 attr() 자체는 널리 사용할 수 있지만, content가 아닌 일반 CSS 속성에서 typed attr()를 사용하는 동작은 아직 실험적이고 브라우저 지원 차이가 있다. 즉 위 코드는 “이렇게 쓸 수 있으면 좋겠다”에 가깝지, 아무 프로젝트에나 바로 넣어도 되는 안정적인 패턴은 아니다.
그래도 실험적으로 써야 한다면 최소한 @supports와 fallback을 같이 둬야 한다.
.box {
width: var(--box-size, 100px);
height: var(--box-size, 100px);
background-color: var(--box-color, #eee);
}
@supports (x: attr(x type(*))) {
.box {
width: attr(data-size px, 100px);
height: attr(data-size px, 100px);
background-color: attr(data-color type(<color>), #eee);
}
}이렇게 해두면 typed attr()를 지원하는 브라우저에서는 HTML 속성 값을 직접 읽고, 그렇지 않은 브라우저에서는 커스텀 속성 fallback을 사용한다. 다만 이 정도로 방어 코드를 넣어야 한다면, 프로덕션에서는 그냥 커스텀 속성을 쓰는 편이 더 명확할 때가 많다.
const style = {
'--box-size': `${size}px`,
'--box-color': '#2563eb',
} as React.CSSProperties;
return <div className={styles.box} style={style}>박스 영역</div>;개인적으로는 선택지가 이렇게 정리된다.
- 값이 런타임에 바뀌고 안정성이 중요하다면 CSS 커스텀 속성을 쓴다.
- 타입, 초기값, 상속 여부, 애니메이션 보간이 중요하다면
@property를 함께 쓴다. attr()는content에서는 편하게 쓰되, 일반 속성의 typedattr()는@supports와 fallback 없이 핵심 로직으로 쓰지 않는다.- Tailwind CSS에서는 클래스 이름을 동적으로 조합하기보다, 가능한 클래스 후보를 코드에 드러내거나 커스텀 속성과 arbitrary value를 조합한다.
동적 스타일링을 전부 자바스크립트로 끌고 오면 당장은 편하다. 하지만 시간이 지나면 스타일의 책임이 컴포넌트 로직과 섞이고, 작은 값 하나를 바꾸는 데도 렌더링과 상태를 같이 생각하게 된다. 반대로 CSS가 잘할 수 있는 계산은 CSS에 남겨두면 구조가 단순해진다. 결국 중요한 것은 “동적인가 정적인가”가 아니라, 어떤 값은 런타임 데이터이고 어떤 규칙은 스타일 시스템의 책임인지 나누는 일이다.
attr()가 더 안정적으로 자리 잡으면 이 선택지는 조금 더 재미있어질 것이다. 그때는 정말 HTML 속성만으로 남들과는 다르게 동적 스타일링을 처리할 수 있을지도 모른다. 그래도 너무 앞서가다 자빠지지는 않는 쪽이 낫다.
댓글
댓글을 불러오는 중...