junyeokk
Blog
CSS·2026. 02. 15

CSS Custom Properties 런타임 테마

웹 애플리케이션에서 테마를 바꾸려면 어떻게 해야 할까? 가장 원시적인 방법은 테마별로 CSS 파일을 따로 만들어서 <link> 태그를 교체하는 것이다.

html
<link id="theme" rel="stylesheet" href="/themes/light.css">
javascript
document.getElementById('theme').href = '/themes/dark.css';

이 방식은 테마 전환 시 새 CSS 파일을 네트워크로 가져와야 해서 깜빡임(FOUC)이 발생한다. 테마가 3개, 5개, 10개로 늘어나면 그만큼 CSS 파일도 늘어나고, 각 파일마다 같은 셀렉터에 다른 값만 넣은 중복 코드가 쌓인다. 새 컴포넌트를 추가할 때마다 모든 테마 파일을 수정해야 하는 것도 고통이다.

Sass 변수를 쓰면 어떨까?

scss
$primary: #3b82f6;
$bg: #ffffff;

.button {
  background: $primary;
  color: $bg;
}

Sass 변수는 컴파일 타임에 값이 확정된다. 빌드하면 $primary#3b82f6이라는 리터럴로 치환되어 CSS에 박히고, 런타임에는 변수라는 개념 자체가 사라진다. 다크모드로 전환하고 싶다면? 다시 빌드해야 한다. 사용자가 "이 색이 좋아요"라며 커스텀 색상을 선택하는 기능은 아예 불가능하다.

CSS Custom Properties(커스텀 속성, 흔히 CSS 변수)는 이 문제를 근본적으로 해결한다. 브라우저가 변수를 런타임에 해석하기 때문에 JavaScript 한 줄로 값을 바꾸면 그 변수를 참조하는 모든 스타일이 즉시 업데이트된다.


기본 문법

CSS Custom Properties는 --로 시작하는 이름을 가지며, var() 함수로 참조한다.

css
:root {
  --color-primary: #3b82f6;
  --color-bg: #ffffff;
  --color-text: #1f2937;
  --radius: 8px;
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
}

.button {
  background: var(--color-primary);
  color: var(--color-bg);
  border-radius: var(--radius);
  padding: var(--spacing-sm) var(--spacing-md);
}

:root는 HTML 문서의 최상위 요소(<html>)를 가리키는 의사 클래스다. 여기에 선언한 변수는 문서 전체에서 사용할 수 있다. 이것이 "전역 변수"를 만드는 관례적인 방법이다.

var()의 폴백 값

var() 함수의 두 번째 인자로 폴백 값을 지정할 수 있다. 변수가 정의되지 않았을 때 사용된다.

css
.card {
  /* --card-bg가 없으면 white 사용 */
  background: var(--card-bg, white);
  
  /* 폴백으로 다른 변수를 참조할 수도 있다 */
  color: var(--card-text, var(--color-text, #333));
}

폴백은 중첩할 수 있다. --card-text가 없으면 --color-text를 찾고, 그것도 없으면 #333을 사용한다. 이 패턴은 컴포넌트별 커스터마이징을 허용하면서도 기본값을 보장할 때 유용하다.


캐스케이드와 상속

CSS Custom Properties가 Sass 변수와 결정적으로 다른 점은 캐스케이드(cascade)와 상속(inheritance)을 따른다는 것이다.

css
:root {
  --color-primary: blue;
}

.sidebar {
  --color-primary: green;
}

.footer {
  --color-primary: orange;
}

.button {
  background: var(--color-primary);
}

같은 .button이라도 어디에 위치하느냐에 따라 배경색이 달라진다. .sidebar 안의 버튼은 초록, .footer 안의 버튼은 주황, 그 외에는 파랑이 된다. Sass 변수는 이런 게 불가능하다. 값이 컴파일 시점에 하나로 확정되기 때문이다.

이 특성은 테마 시스템에서 핵심적인 역할을 한다. 특정 영역에만 다른 테마를 적용하거나, 컴포넌트 단위로 변수를 오버라이드하는 것이 자연스럽게 가능하다.

css
/* 특정 섹션만 다크 테마 */
.dark-section {
  --color-bg: #1a1a2e;
  --color-text: #e0e0e0;
  --color-primary: #64b5f6;
}

.dark-section 안에 있는 모든 자식 요소는 다크 테마 변수를 상속받는다. 페이지 전체는 라이트 테마인데 특정 영역만 다크 테마를 적용하는 것이 CSS 한 블록으로 끝난다.


런타임에 테마 전환하기

CSS Custom Properties의 진짜 힘은 JavaScript로 런타임에 값을 바꿀 수 있다는 것이다.

style.setProperty

element.style.setProperty()로 특정 요소의 커스텀 속성 값을 동적으로 설정한다.

javascript
// 전역 변수 변경 (문서 전체에 영향)
document.documentElement.style.setProperty('--color-primary', '#ef4444');

// 특정 요소의 변수만 변경
const card = document.querySelector('.card');
card.style.setProperty('--card-bg', '#fef3c7');

document.documentElement<html> 요소다. 여기에 setProperty를 호출하면 :root에서 선언한 변수를 오버라이드하는 효과가 생긴다. 인라인 스타일은 CSS 명시도(specificity)에서 가장 높기 때문에, :root에 선언된 값보다 우선한다.

getPropertyValue로 현재 값 읽기

javascript
// 인라인 스타일에서 읽기
const inline = element.style.getPropertyValue('--color-primary');

// 계산된 최종 값 읽기 (상속 포함)
const computed = getComputedStyle(element).getPropertyValue('--color-primary');

getComputedStyle을 사용하면 상속과 캐스케이드가 모두 적용된 최종 값을 얻을 수 있다. 인라인 스타일에 직접 설정한 값만 필요하다면 element.style.getPropertyValue를 사용한다.

removeProperty로 오버라이드 제거

javascript
// 인라인 오버라이드 제거 → 원래 CSS 값으로 복원
document.documentElement.style.removeProperty('--color-primary');

setProperty로 설정한 인라인 값을 제거하면, CSS에서 선언한 원래 값이 다시 적용된다. 테마를 "기본값으로 리셋"하는 기능을 구현할 때 유용하다.


테마 시스템 설계 패턴

데이터 속성 기반 테마 전환

가장 널리 쓰이는 패턴이다. data-theme 같은 HTML 속성으로 테마를 구분하고, 속성 셀렉터로 변수 세트를 전환한다.

css
:root,
[data-theme="light"] {
  --color-bg: #ffffff;
  --color-surface: #f8fafc;
  --color-text: #1e293b;
  --color-text-muted: #64748b;
  --color-primary: #3b82f6;
  --color-primary-hover: #2563eb;
  --color-border: #e2e8f0;
  --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.05);
  --shadow-md: 0 4px 6px rgb(0 0 0 / 0.1);
}

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-surface: #1e293b;
  --color-text: #f1f5f9;
  --color-text-muted: #94a3b8;
  --color-primary: #60a5fa;
  --color-primary-hover: #93bbfd;
  --color-border: #334155;
  --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.3);
  --shadow-md: 0 4px 6px rgb(0 0 0 / 0.4);
}
javascript
function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
}

// 초기 로드 시 저장된 테마 복원
const saved = localStorage.getItem('theme') || 'light';
setTheme(saved);

이 패턴의 장점은 CSS만으로 테마 정의가 완결된다는 것이다. JavaScript는 data-theme 속성만 토글하면 되고, 어떤 색이 어떻게 바뀌는지는 몰라도 된다. 새 테마를 추가할 때도 CSS에 [data-theme="new-theme"] 블록만 추가하면 끝이다.

시스템 설정 연동 (prefers-color-scheme)

사용자의 OS 다크모드 설정을 감지해서 자동으로 테마를 적용할 수 있다.

css
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg: #0f172a;
    --color-surface: #1e293b;
    --color-text: #f1f5f9;
    /* ... 나머지 다크 변수 */
  }
}

:root:not([data-theme="light"])를 사용하면 사용자가 명시적으로 라이트 테마를 선택한 경우에는 시스템 설정을 무시한다. JavaScript에서도 감지할 수 있다.

javascript
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');

// 현재 상태 확인
if (prefersDark.matches) {
  setTheme('dark');
}

// 시스템 설정 변경 감지
prefersDark.addEventListener('change', (e) => {
  if (!localStorage.getItem('theme')) {
    // 사용자가 수동으로 설정한 적 없으면 시스템 따라감
    setTheme(e.matches ? 'dark' : 'light');
  }
});

의미적(semantic) 변수 레이어링

변수를 두 단계로 나누면 유지보수가 훨씬 편해진다.

css
:root {
  /* 1단계: 원시(primitive) 색상 팔레트 */
  --blue-50: #eff6ff;
  --blue-500: #3b82f6;
  --blue-600: #2563eb;
  --blue-700: #1d4ed8;
  --slate-50: #f8fafc;
  --slate-100: #f1f5f9;
  --slate-800: #1e293b;
  --slate-900: #0f172a;

  /* 2단계: 의미적(semantic) 토큰 */
  --color-bg: var(--slate-50);
  --color-text: var(--slate-800);
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-600);
}

[data-theme="dark"] {
  /* 의미적 토큰만 재매핑 */
  --color-bg: var(--slate-900);
  --color-text: var(--slate-100);
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-700);
}

원시 팔레트는 고정이다. 테마 전환은 의미적 토큰이 어떤 원시 색상을 가리키는지만 바꾸면 된다. 컴포넌트 CSS에서는 항상 의미적 토큰만 참조하므로 테마와 완전히 분리된다.

css
/* 컴포넌트는 의미적 토큰만 사용 */
.button {
  background: var(--color-primary);
  color: var(--color-bg);
}

.button:hover {
  background: var(--color-primary-hover);
}

이 패턴은 디자인 시스템에서 디자인 토큰(design token)이라고 부르는 접근법과 동일하다. Figma에서 디자이너가 "primary"를 정의하고, 개발자가 --color-primary를 사용하면 커뮤니케이션 비용이 줄어든다.


Tailwind CSS와 연동

Tailwind CSS에서 CSS Custom Properties를 테마 값으로 사용하면 유틸리티 클래스와 런타임 테마 전환을 동시에 쓸 수 있다.

Tailwind v3

javascript
// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: 'var(--color-primary)',
        background: 'var(--color-bg)',
        foreground: 'var(--color-text)',
        muted: 'var(--color-text-muted)',
        border: 'var(--color-border)',
        surface: 'var(--color-surface)',
      },
    },
  },
};
html
<div class="bg-background text-foreground">
  <button class="bg-primary hover:opacity-90">버튼</button>
</div>

bg-primarybackground: var(--color-primary)로 컴파일된다. data-theme이 바뀌면 변수 값이 바뀌고, Tailwind 클래스는 그대로인 채 색이 전환된다.

투명도 지원 (rgb 분리)

Tailwind의 bg-primary/50 같은 투명도 수정자(modifier)를 쓰려면 변수에 rgb 채널 값만 넣어야 한다.

css
:root {
  --color-primary: 59 130 246; /* #3b82f6의 rgb 채널 */
}
javascript
// tailwind.config.js
colors: {
  primary: 'rgb(var(--color-primary) / <alpha-value>)',
}

이렇게 하면 bg-primary/50background: rgb(59 130 246 / 0.5)로 컴파일되어 투명도 조절이 가능하다.

Tailwind v4

Tailwind v4에서는 @theme 지시어로 CSS 파일 안에서 직접 토큰을 정의한다.

css
@theme {
  --color-primary: var(--color-primary-token);
  --color-background: var(--color-bg-token);
}

설정 파일 없이 CSS만으로 테마를 구성할 수 있게 바뀌었다.


사용자 커스텀 테마

CSS Custom Properties의 진짜 "런타임" 능력은 사용자가 직접 색상을 골라서 적용하는 기능에서 빛난다. Sass 변수로는 절대 불가능한 영역이다.

javascript
function applyUserTheme(colors) {
  const root = document.documentElement;
  
  Object.entries(colors).forEach(([key, value]) => {
    root.style.setProperty(`--color-${key}`, value);
  });
}

// 컬러 피커에서 사용자가 선택한 색상 적용
colorPicker.addEventListener('input', (e) => {
  applyUserTheme({ primary: e.target.value });
});

이 패턴을 확장하면 Notion 같은 앱에서 페이지별로 다른 커버 색상을 적용하거나, SaaS에서 고객사별 브랜드 색상을 적용하는 화이트라벨(white-label) 기능을 구현할 수 있다.

javascript
// API에서 고객사 브랜드 색상 가져와서 적용
async function loadBrandTheme(tenantId) {
  const res = await fetch(`/api/tenants/${tenantId}/theme`);
  const theme = await res.json();
  
  const root = document.documentElement;
  root.style.setProperty('--color-primary', theme.primaryColor);
  root.style.setProperty('--color-primary-hover', theme.primaryHoverColor);
  root.style.setProperty('--color-accent', theme.accentColor);
}

React에서의 활용

테마 Context 패턴

tsx
type Theme = 'light' | 'dark' | 'system';

const ThemeContext = createContext<{
  theme: Theme;
  setTheme: (theme: Theme) => void;
} | null>(null);

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(() => {
    return (localStorage.getItem('theme') as Theme) || 'system';
  });

  useEffect(() => {
    const root = document.documentElement;
    
    if (theme === 'system') {
      root.removeAttribute('data-theme');
      // prefers-color-scheme 미디어 쿼리가 처리
    } else {
      root.setAttribute('data-theme', theme);
    }
    
    localStorage.setItem('theme', theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be within ThemeProvider');
  return ctx;
}

인라인에서 변수 참조

React 컴포넌트의 인라인 스타일에서도 CSS 변수를 참조할 수 있다.

tsx
function ProgressBar({ progress }: { progress: number }) {
  return (
    <div
      style={{
        width: `${progress}%`,
        backgroundColor: 'var(--color-primary)',
        height: 'var(--spacing-sm)',
        transition: 'width 0.3s ease',
      }}
    />
  );
}

컴포넌트 레벨 변수 오버라이드

특정 컴포넌트 인스턴스의 색상만 바꾸고 싶을 때, prop으로 CSS 변수를 주입할 수 있다.

tsx
function Card({ 
  accentColor, 
  children 
}: { 
  accentColor?: string; 
  children: React.ReactNode;
}) {
  return (
    <div 
      className="card" 
      style={accentColor ? { '--card-accent': accentColor } as React.CSSProperties : undefined}
    >
      {children}
    </div>
  );
}
css
.card {
  border-left: 3px solid var(--card-accent, var(--color-primary));
}

--card-accent가 주입되면 그 값을, 아니면 --color-primary를 폴백으로 사용한다. TypeScript에서는 as React.CSSProperties로 타입 단언이 필요하다. 커스텀 속성은 React의 CSSProperties 타입에 포함되어 있지 않기 때문이다.


FOUC 방지

테마 전환에서 가장 흔한 문제가 FOUC(Flash of Unstyled Content)다. 페이지 로드 시 라이트 테마가 잠깐 보였다가 다크 테마로 전환되는 깜빡임이다.

이 문제가 발생하는 이유는 간단하다. HTML이 먼저 렌더링되고, JavaScript가 나중에 실행되어 data-theme을 설정하기 때문이다. 그 사이 시간에 기본 테마(보통 라이트)가 보인다.

해결법: 블로킹 스크립트

<head>에 인라인 스크립트를 넣어서 렌더링 전에 테마를 설정한다.

html
<head>
  <script>
    // 렌더링을 블로킹해서 FOUC 방지
    (function() {
      const theme = localStorage.getItem('theme');
      if (theme === 'dark' || 
          (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        document.documentElement.setAttribute('data-theme', 'dark');
      }
    })();
  </script>
  <link rel="stylesheet" href="/styles.css">
</head>

이 스크립트는 반드시 CSS <link> 태그보다 앞에 있어야 한다. CSS가 파싱될 때 이미 data-theme 속성이 설정되어 있으므로 올바른 테마의 변수가 처음부터 적용된다.

Next.js에서의 FOUC 방지

Next.js에서는 next-themes 라이브러리가 이 문제를 자동으로 처리한다. 직접 구현하려면 _document.tsx에서 블로킹 스크립트를 삽입한다.

tsx
// pages/_document.tsx (Pages Router)
<Head>
  <script dangerouslySetInnerHTML={{
    __html: `
      (function() {
        var t = localStorage.getItem('theme');
        if (t === 'dark' || (!t && matchMedia('(prefers-color-scheme:dark)').matches)) {
          document.documentElement.setAttribute('data-theme', 'dark');
        }
      })();
    `
  }} />
</Head>

성능 고려사항

리페인트 범위

document.documentElementsetProperty를 호출하면 해당 변수를 참조하는 모든 요소가 리페인트된다. 전체 테마 전환은 어차피 전부 다시 그려야 하니 괜찮지만, 스크롤이나 마우스 이동 같은 고빈도 이벤트에서 전역 변수를 변경하면 성능 문제가 생길 수 있다.

javascript
// ❌ 나쁜 예: 매 마우스 이동마다 전역 변수 변경
window.addEventListener('mousemove', (e) => {
  document.documentElement.style.setProperty('--mouse-x', e.clientX + 'px');
});

// ✅ 좋은 예: 특정 요소에만 변수 설정
const target = document.querySelector('.interactive-area');
target.addEventListener('mousemove', (e) => {
  target.style.setProperty('--mouse-x', e.clientX + 'px');
});

변수를 가능한 한 좁은 범위의 요소에 설정하면 리페인트 범위가 줄어든다.

트랜지션 적용

테마 전환 시 모든 색상이 한꺼번에 바뀌면 눈이 피로하다. transition을 추가하면 부드럽게 전환된다.

css
:root {
  transition: background-color 0.2s ease, color 0.2s ease;
}

/* 또는 모든 색 관련 속성에 일괄 적용 */
* {
  transition: background-color 0.2s ease, 
              color 0.2s ease, 
              border-color 0.2s ease,
              box-shadow 0.2s ease;
}

단, *에 트랜지션을 거는 것은 성능 비용이 있다. 요소가 수백 개면 그만큼 트랜지션 계산이 발생한다. 테마 전환 버튼을 누를 때만 일시적으로 트랜지션을 적용하는 방법도 있다.

javascript
function setThemeWithTransition(theme) {
  document.documentElement.classList.add('theme-transition');
  document.documentElement.setAttribute('data-theme', theme);
  
  // 트랜지션 끝나면 클래스 제거
  setTimeout(() => {
    document.documentElement.classList.remove('theme-transition');
  }, 300);
}
css
.theme-transition,
.theme-transition * {
  transition: background-color 0.3s ease, color 0.3s ease !important;
}

@property로 타입 지정

@property 규칙을 사용하면 커스텀 속성에 타입, 상속 여부, 초기값을 명시적으로 선언할 수 있다.

css
@property --color-primary {
  syntax: '<color>';
  inherits: true;
  initial-value: #3b82f6;
}

@property --radius {
  syntax: '<length>';
  inherits: true;
  initial-value: 8px;
}

@property --opacity-overlay {
  syntax: '<number>';
  inherits: false;
  initial-value: 0.5;
}

이것의 실질적인 장점은 애니메이션이다. 일반 커스텀 속성은 브라우저가 문자열로 취급해서 보간(interpolation)이 불가능하다. @property<color> 타입을 선언하면 색상 간 부드러운 트랜지션이 가능해진다.

css
@property --gradient-start {
  syntax: '<color>';
  inherits: false;
  initial-value: #3b82f6;
}

.hero {
  background: linear-gradient(var(--gradient-start), transparent);
  transition: --gradient-start 0.5s ease;
}

.hero:hover {
  --gradient-start: #ef4444;
}

타입 선언 없이는 linear-gradient에서 색상이 뚝 끊어지며 바뀌지만, @property<color>를 선언하면 부드럽게 보간된다.


Sass/SCSS와의 공존

실무에서는 Sass와 CSS Custom Properties를 함께 사용하는 경우가 많다. Sass의 기능(mixin, 함수, 네스팅)과 CSS Custom Properties의 런타임 동적 특성을 조합하는 것이다.

scss
// Sass 맵으로 테마 정의
$themes: (
  'light': (
    'bg': #ffffff,
    'text': #1e293b,
    'primary': #3b82f6,
  ),
  'dark': (
    'bg': #0f172a,
    'text': #f1f5f9,
    'primary': #60a5fa,
  ),
);

// 자동으로 CSS Custom Properties 생성
@each $theme-name, $theme-values in $themes {
  [data-theme="#{$theme-name}"] {
    @each $key, $value in $theme-values {
      --color-#{$key}: #{$value};
    }
  }
}

이렇게 하면 테마 색상 정의는 Sass 맵 한 곳에서 관리하고, 실제 런타임 전환은 CSS Custom Properties가 담당한다. 테마가 10개, 20개로 늘어나도 반복적인 CSS 작성 없이 Sass 맵만 확장하면 된다.


브라우저 지원

CSS Custom Properties는 모든 최신 브라우저에서 지원한다.

  • Chrome 49+
  • Firefox 31+
  • Safari 9.1+
  • Edge 15+

IE는 지원하지 않는다. IE 대응이 필요하다면 PostCSS 플러그인(postcss-custom-properties)으로 빌드 시 정적 값으로 폴백을 생성할 수 있지만, 이 경우 런타임 전환 기능은 당연히 동작하지 않는다.

@property 규칙은 Chrome 85+, Edge 85+, Safari 15.4+에서 지원한다. Firefox는 128+에서 지원을 시작했다.


관련 문서


정리

  • CSS Custom Properties는 런타임에 값이 해석되므로 JavaScript 한 줄로 변수를 바꾸면 참조하는 모든 스타일이 즉시 반영된다
  • 원시 팔레트와 의미적 토큰을 분리하는 2단계 레이어링을 적용하면 테마 추가가 CSS 한 블록으로 끝나고, 컴포넌트 코드는 테마와 완전히 분리된다
  • data 속성 기반 전환 + <head> 블로킹 스크립트 조합이 FOUC 없는 테마 시스템의 표준 패턴이다