두 가지 색 구성표, 네 가지 모드: 네이티브 CSS 테마 전환
Source: Dev.to
위에 제공된 소스 링크 외에 번역할 텍스트가 포함되어 있지 않습니다. 번역이 필요한 전체 내용을 제공해 주시면 한국어로 번역해 드리겠습니다.
The Idea
프론트엔드가 마침내 vanilla‑ization으로 나아가고 있으며, 이는 Wes Bos가 첫 강좌를 공개한 이후 프론트엔드에 일어난 최고의 일입니다. 테마에 대한 네이티브 브라우저 지원은 완전히 잘 작동하고 많은 프로젝트에 충분할 수 있지만, 저는 그냥 재미를 느끼고 싶었습니다. 저는 밝고 어두운 변형을 가진 하나의 색상 스키마만 원하지 않았습니다. 저는 각각 밝고 어두운 모드를 지원하는 두 개의 스키마, 즉 총 네 가지 변형을 원했고, 이를 오직 네이티브 브라우저 기능만으로 구현하고 싶었습니다. 그리고 성공했습니다.
The Requirements
- 브라우저가 기본적으로 처리하는 라이트 및 다크 모드
- 완전히 다른 팔레트를 가진 두 번째 “spring”(또는 기타) 색상 스킴
- default와 spring 사이를 전환하는 토글 버튼
- 새로 고침 시 잘못된 테마가 깜빡이지 않음
- 전환을 위해 페이지 새로 고침이 필요 없음
The Specificity Hack
:root:root:root
만약 styled‑components를 사용한다면, 전역 CSS 변수가 styled‑components가 주입하는 스타일에 의해 덮어씌워지는 specificity 전쟁을 겪어봤을 것입니다. 해결 방법은 아주 단순합니다: :root를 세 번 반복하면 됩니다.
Note: styled‑components는 오래된 기술이며, 앞으로는 이를 없애고 싶지만 이 글의 주요 주제는 아닙니다.
:root:root:root {
--color-background: light-dark(
oklch(0.99 0.0105 320.98),
oklch(13.709% 0.02553 268.319)
);
--color-primary: light-dark(
oklch(70.61% 0.085 271.69),
oklch(0.79 0.1233 266.14)
);
/* …etc */
}
브라우저가 사용자의 시스템 선호도에 따라 올바른 값을 자동으로 선택합니다 – JavaScript가 필요 없습니다. 이를 작동시키려면 color-scheme 선언이 필요합니다:
<!-- Example: declare color-scheme in HTML -->
<html color-scheme="light dark">
…
</html>
Important: 아직 light-dark()를 지원하지 않는 브라우저를 위해, 아래에 @media (prefers-color-scheme: …) 폴백 블록을 추가해 두세요. 캐스케이딩이 이를 부드럽게 처리합니다.
스프링 테마 – 하나의 CSS 파일, <html>에 클래스
두 번째 테마에서는 동적 CSS import를 피하고 싶었습니다(비동기이며 Vite를 사용한 프로덕션 빌드에서 신뢰성이 떨어지고 복잡성을 더합니다). 대신 두 테마를 하나의 CSS 파일에 포함시켰습니다.
스프링 테마는 <html> 요소에 .spring 클래스를 적용하여 기본 변수를 재정의합니다. 핵심 포인트는 특이성(specificity)이며, .spring:root:root:root는 추가된 클래스 때문에 :root:root:root보다 우선합니다.
/* default theme */
:root:root:root {
--color-background: light-dark(
oklch(0.99 0.0105 320.98),
oklch(0.21 0.037 271.06)
);
--color-primary: light-dark(
oklch(70.61% 0.085 271.69),
oklch(0.79 0.1233 266.14)
);
}
/* spring theme – wins when .spring is on <html> */
.spring:root:root:root {
--color-background: light-dark(
oklch(0.99 0.012 150),
oklch(0.18 0.05 145)
);
--color-primary: light-dark(
oklch(56.316% 0.10067 150.907),
oklch(0.72 0.2 145)
);
}
두 테마 모두에서 밝은 모드와 어두운 모드는 여전히 자동으로 작동하며, light-dark()는 활성화된 테마와 관계없이 계속 제 역할을 합니다.
토글
classList.toggle()는 클래스를 전환하고 새로운 불리언 값을 반환합니다. 이것이 전체 토글 로직이며, React 상태도, 컨텍스트도, 재렌더도 없습니다. CSS는 클래스 변경이 DOM에 즉시 반영되기 때문에 바로 반응합니다.
// UI component
function handleToggle() {
const isSpring = document.documentElement.classList.toggle("spring");
localStorage.setItem("theme", isSpring ? "spring" : "default");
}
로드 시 올바른 테마 적용
첫 번째 페인트 이전에 동기식으로 실행하여 플래시, 레이아웃 이동, 또는 useEffect 타이밍 문제를 방지하세요.
// main.tsx
import "./themes.css";
const savedTheme = localStorage.getItem("theme");
document.documentElement.classList.toggle("spring", savedTheme === "spring");
ReactDOM.createRoot(document.getElementById("root")!).render(
<App />
);
작동하지 않은 시도
이 솔루션에 도달하기 전에 @container style() 쿼리를 실험해 보았습니다. 이는 부모 요소의 커스텀 속성 값에 따라 스타일을 적용할 수 있게 하는 최신 CSS 기능입니다:
@container style(--theme: spring) {
body {
--color-background: …;
}
}
Chrome에서는 작동했지만 Firefox Developer Edition에서는 동작하지 않았고, Safari는 전혀 지원하지 않았습니다. 그래서 포기했지만, 앞으로는 눈여겨볼 가치가 있습니다. 더 우아한 접근 방식이 될 수 있었으니까요.
결과
- 하나의 CSS 파일에 네 가지 테마 변형(light default, dark default, light spring, dark spring) 모두 포함
- 색상 값을 위한 JavaScript 전혀 없음 – 모든 색상은 CSS에 존재
- 테마 적용을 위한 React 컨텍스트나 상태 없음
- 토글 시 페이지 새로고침 없음
- 로드 시 잘못된 테마가 깜빡이는 현상 없음
- 라이트/다크 선호도에 대한 브라우저 기본 처리