RSS듀오랩스

색 테마 20종을 위한 리팩토링: 하드코딩된 색 100곳과 캔버스 모듈 분리

디자인 시스템글: , Duolabs10분 읽기cssfrontendrefactoringtechnical-notetheming

회사 홈페이지에 색 테마를 붙였습니다. 다크·라이트는 이미 있었고, 거기에 색 팔레트를 고르는 축을 하나 더 얹는 일이었습니다.

CSS 변수를 쓰고 있었으니 금방 끝날 줄 알았습니다. 팔레트 블록을 만들고 강조색 다섯 개를 덮은 뒤 화면을 보니, 파랑에서 주황으로 바꿨는데 거의 그대로였습니다.

픽셀을 세어 봤습니다.

코발트 vs 앰버 : 1,426 / 1,170,720 = 0.12%

색 테마를 넣는 일의 대부분은 테마를 만드는 게 아니라 리팩토링이었습니다.

축을 새로 만들지, 기존 축에 태울지

먼저 정할 게 있었습니다. 사내 다른 프로젝트(대시보드)는 data-theme 자체를 팔레트로 씁니다. data-theme="tiger" 같은 식이고, 다크·라이트는 .dark 클래스가 따로 맡습니다.

같은 방식을 쓰려다 그만뒀습니다. 이 프로젝트는 [data-theme="light"]로 시작하는 규칙이 수십 개입니다. 속성의 의미를 바꾸면 그게 전부 깨집니다.

축을 하나 더 두는 쪽으로 갔습니다.

<html data-theme="dark" data-palette="amber">
[data-palette="amber"] { --accent: #ea580c; }
[data-palette="amber"][data-theme="dark"] { --accent: #f97316; }

기존 규칙은 한 줄도 안 건드립니다. 명암은 명암대로, 색은 색대로 갑니다.

변수를 쓰는 곳이 아니라 안 쓰는 곳이 문제였습니다

0.12%의 원인은 단순했습니다. 세어 보니 이랬습니다.

개수
var(--accent…) 를 쓰는 곳 270
색이 직접 박힌 곳 약 100

비율만 보면 변수 쪽이 훨씬 많습니다. 그런데 하드코딩된 100곳이 눈에 띄는 자리에 몰려 있었습니다. 히어로 그라디언트, 보더빔, 오로라 배경, 섹션 칩, CTA 밴드. 화려한 것부터 손으로 색을 넣었으니 당연한 결과였습니다.

문제는 그냥 치환이 안 된다는 점이었습니다. 대부분 투명도가 섞인 형태였습니다.

background: rgba(37, 99, 235, 0.16);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.07);

var(--accent)로 바꾸면 알파가 사라집니다. color-mix()로 환원했습니다.

background: color-mix(in srgb, var(--accent) 16%, transparent);

rgba(브랜드색, A)color-mix(in srgb, var(--accent) A%, transparent)로 옮기는 기계적인 규칙이라 스크립트로 돌렸습니다. 73줄이 바뀌었고, 라이트 모드에서는 값이 수학적으로 같아 기본 팔레트의 외관은 변하지 않았습니다.

치환 후 다시 재보니 이렇게 됐습니다.

코발트 vs 앰버 : 853,887 / 1,170,720 = 73%

일부러 남긴 것들

전부 바꾸지는 않았습니다. 제품 목업 안의 색은 그대로 뒀습니다.

랜딩에는 자체 제품 화면을 흉내 낸 목업이 들어갑니다. 그 안의 브랜드색이 방문자의 테마 선택을 따라 바뀌면, 그 제품을 잘못 그리는 것이 됩니다. 목업은 제품의 색을 보여주는 자리지 사이트의 색을 보여주는 자리가 아닙니다.

캔버스는 CSS 변수를 못 읽습니다

이 사이트의 히어로는 캔버스 세 개가 그립니다. 점을 잇는 그물, 파동, 오로라 커튼입니다.

캔버스는 매 프레임 색 문자열을 조립합니다.

ctx.strokeStyle = `rgba(${lineRgb},${alpha})`;

getComputedStyle--accent를 읽어 파싱할 수는 있습니다. 그런데 캔버스가 쓰는 색은 강조색 하나가 아닙니다. 선 색, 점 색, 중앙 글로, 커튼 세 겹의 색이 다 다르고, 명암에 따라 또 갈립니다. 어차피 팔레트마다 손으로 잡아야 하는 값입니다.

그래서 CSS에서 읽지 않고 모듈로 뺐습니다.

// paletteTones.ts
export function activePalette(): PaletteId {
  const p = document.documentElement.dataset.palette;
  return p === "jade" || p === "amber" ? p : "cobalt";
}

export const CURTAIN_HUES: Record<PaletteId, [number, number, number][]> = {
  cobalt: [[125, 211, 252], [92, 157, 255], [110, 120, 245]],
  amber:  [[255, 199, 89], [255, 138, 76], [255, 110, 160]],
};

캔버스 쪽은 프레임마다 이걸 읽습니다.

const hues = CURTAIN_HUES[activePalette()];

원래 dataset.theme을 프레임마다 읽어 명암을 판정하고 있었으니 같은 패턴입니다. 전환 이벤트를 배선할 필요 없이 팔레트를 바꾼 다음 프레임부터 따라옵니다.

다크는 되는데 라이트는 새로고침해야 했습니다

여기서 한참 헤맸습니다. 다크 모드에서는 팔레트를 바꾸면 히어로가 즉시 바뀌는데, 라이트에서는 새로고침해야 바뀌었습니다.

이 사이트는 명암에 따라 다른 캔버스를 씁니다. 다크는 파동, 라이트는 오로라 커튼입니다. 그러니 커튼 쪽만의 문제였습니다.

원인은 캐시였습니다. 커튼은 그라디언트를 매 프레임 만들면 비싸서 캐싱합니다.

if (fills.length !== curtains.length || fillsDark !== dark) {
  fills = curtains.map(...);
  fillsDark = dark;
}

키가 명암뿐입니다. 팔레트만 바꾸면 키가 그대로라 캐시가 살아남고, 옛 색을 계속 칠합니다. 새로고침해야 바뀐 이유가 이것이었습니다.

const fillsKey = `${paletteId}:${dark}`;
if (fills.length !== curtains.length || fillsSig !== fillsKey) { ... }

파동 쪽에도 같은 캐시가 있었는데 그쪽은 키를 색 문자열 자체로 쓰고 있어서 우연히 멀쩡했습니다. 축을 하나 늘리면 그 축을 키에 포함하는 캐시를 전부 찾아야 합니다.

변수를 세 개 더 만들었습니다

팔레트를 늘리다 보니 강조색 다섯 개로는 부족한 자리가 나왔습니다. 그때마다 팔레트 블록에서 덮을 수 있게 변수를 하나씩 팠습니다.

--accent-fg : 강조색 위 글자색

강조색이 밝은 팔레트를 만들다 흰 버튼에 흰 글자가 나왔습니다. 그때까지 color: #fff가 15곳에 박혀 있었습니다.

.btn-primary { background: var(--accent); color: var(--accent-fg); }

기본값은 #fff이고, 강조가 밝은 팔레트만 그 블록에서 뒤집습니다.

--accent-grad : 면을 무엇으로 칠할지

단색 팔레트와 그라디언트 팔레트를 같이 두고 싶었습니다. 기본값을 단색으로 잡으면 나머지는 코드가 그대로입니다.

:root { --accent-grad: var(--accent); }
[data-palette="opal"] { --accent-grad: linear-gradient(100deg, #8cf5ce, #f8d26b, #f0924c); }

여기서 배운 게 하나 있습니다. 밝은 끝과 짙은 끝이 한 버튼에 같이 있으면 글자색을 어느 쪽에 맞춰도 절반이 안 읽힙니다. 그래서 다크는 밝은 구간만, 라이트는 짙은 구간만 쓰도록 명암별로 그라디언트를 다르게 잡았습니다.

--hero-veil : 배경 위에 얹는 판

히어로 위의 카드가 딥그린 배경에서 네이비 사각형으로 떴습니다. 처음에는 알파를 낮추려 했는데, 옅어질 뿐 여전히 다른 색이었습니다. 딥그린 위 네이비는 흐려도 네이비입니다.

색 자체가 팔레트를 따라와야 했습니다.

:root { --hero-veil: #060a18; }
.landing-hero-card { background: color-mix(in srgb, var(--hero-veil) 62%, transparent); }

히어로 바닥과 같은 색에 알파를 주니 "같은 색을 옅게 덮은 유리"가 되어 배경과 이어지고, 뒤의 캔버스도 비칩니다.

결과

지금은 팔레트를 하나 더하는 데 세 곳만 손대면 됩니다.

파일 하는 일
globals.css 팔레트 블록 하나 (강조 5 + 표면 6 + 베일 1)
paletteTones.ts 캔버스 톤 항목 하나 (점망·파동·커튼)
레지스트리 타입·목록·표시명 각 한 줄

그렇게 20종을 만들었습니다.

되짚어 보면

CSS 변수를 쓰고 있었다는 건 테마를 넣을 준비가 됐다는 뜻이 아니었습니다. 변수는 있었지만 눈에 띄는 자리일수록 손으로 색을 넣어 뒀고, 그 자리들이 정확히 테마가 바뀌어야 하는 곳이었습니다.

그리고 축을 하나 늘리는 일은 CSS만의 문제가 아니었습니다. 캐시 키, 캔버스의 색 테이블, 브라우저 크롬 색, 첫 페인트 전에 도는 인라인 스크립트까지 전부 그 축을 알아야 했습니다. 어딘가 하나가 모르면 "새로고침하면 되는데요" 같은 버그가 남습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.