적응형 접근성 테마 전환 구성요소를 빌드하는 방법에 관한 기본 개요입니다.
이 게시물에서는 어두운 테마와 밝은 테마 전환 구성요소를 빌드하는 방법에 관한 생각을 공유하고자 합니다. 데모 사용해 보기
동영상을 선호하는 경우 이 게시물의 YouTube 버전을 확인하세요.
개요
웹사이트는 시스템 환경설정에 전적으로 의존하는 대신 색 구성표를 제어하는 설정을 제공할 수 있습니다. 즉, 사용자가 시스템 환경설정이 아닌 모드로 탐색할 수 있습니다. 예를 들어 사용자의 시스템은 밝은 테마이지만 사용자는 웹사이트가 어두운 테마로 표시되기를 선호합니다.
이 기능을 빌드할 때는 몇 가지 웹 엔지니어링 고려사항이 있습니다. 예를 들어 페이지 색상 깜박임을 방지하기 위해 브라우저가 최대한 빨리 환경설정을 인식해야 하며 컨트롤은 먼저 시스템과 동기화한 다음 클라이언트 측에 저장된 예외를 허용해야 합니다.

마크업
토글에는 <button>
를 사용해야 합니다. 그러면 클릭 이벤트, 포커스 가능성과 같은 브라우저 제공 상호작용 이벤트와 기능을 활용할 수 있습니다.
버튼
버튼에는 CSS에서 사용할 클래스와 JavaScript에서 사용할 ID가 필요합니다.
또한 버튼 콘텐츠가 텍스트가 아닌 아이콘이므로 버튼의 목적에 관한 정보를 제공하기 위해 title 속성을 추가합니다. 마지막으로 아이콘 버튼의 상태를 유지하는 [aria-label]
를 추가하여 스크린 리더가 시각장애인에게 테마의 상태를 공유할 수 있도록 합니다.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
>
…
</button>
aria-label
및 aria-live
공손함
aria-label
의 변경사항을 알리도록 스크린 리더에 표시하려면 버튼에 aria-live="polite"
를 추가합니다.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
aria-live="polite"
>
…
</button>
이 마크업 추가는 스크린 리더에 aria-live="assertive"
대신 변경된 내용을 사용자에게 정중하게 알리도록 신호를 보냅니다. 이 버튼의 경우 aria-label
가 어떻게 되었는지에 따라 '밝음' 또는 '어두움'을 알립니다.
확장 가능한 벡터 그래픽 (SVG) 아이콘
SVG는 최소한의 마크업으로 고품질의 확장 가능한 도형을 만드는 방법을 제공합니다. 버튼과 상호작용하면 벡터의 새로운 시각적 상태가 트리거될 수 있으므로 SVG는 아이콘에 적합합니다.
다음 SVG 마크업은 <button>
내부에 있습니다.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
…
</svg>
aria-hidden
이 SVG 요소에 추가되어 스크린 리더가 이를 프레젠테이션으로 표시된 것으로 인식하여 무시합니다. 이는 버튼 내부의 아이콘과 같은 시각적 장식에 적합합니다. 요소의 필수 viewBox
속성 외에도 이미지에 인라인 크기를 지정해야 하는 것과 비슷한 이유로 높이와 너비를 추가합니다.
태양
태양 그래픽은 원과 선으로 구성되어 있으며 SVG에는 이러한 도형이 있습니다. cx
및 cy
속성을 표시 영역 크기 (24)의 절반인 12로 설정하여 <circle>
을 중앙에 배치한 다음 크기를 설정하는 반경 (r
) 6
를 지정합니다.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
</svg>
또한 마스크 속성은 다음에 만들 SVG 요소의 ID를 가리키고 마지막으로 currentColor
를 사용하여 페이지의 텍스트 색상과 일치하는 채우기 색상이 지정됩니다.
햇빛
다음으로, 원 바로 아래에 그룹 요소 <g>
그룹 내에 햇살 선이 추가됩니다.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</g>
</svg>
이번에는 fill 값이 currentColor
가 아닌 각 선의 stroke가 설정됩니다. 선과 원 모양이 합쳐져 광선이 있는 멋진 태양을 만듭니다.
달
빛 (태양)과 어둠(달) 사이의 원활한 전환이라는 착시를 만들기 위해 달은 SVG 마스크를 사용하여 태양 아이콘을 보강한 것입니다.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
…
</g>
<mask class="moon" id="moon-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<circle cx="24" cy="10" r="6" fill="black" />
</mask>
</svg>

SVG를 사용한 마스크는 강력하여 흰색과 검은색을 사용하여 다른 그래픽의 일부를 삭제하거나 포함할 수 있습니다. SVG 마스크가 있는 달 <circle>
모양으로 해 아이콘을 가릴 수 있습니다. 원 모양을 마스크 영역 안팎으로 이동하기만 하면 됩니다.
CSS가 로드되지 않으면 어떻게 되나요?

CSS가 로드되지 않은 것처럼 SVG를 테스트하여 결과가 지나치게 크거나 레이아웃 문제가 발생하지 않는지 확인하는 것이 좋습니다. SVG의 인라인 높이 및 너비 속성과 currentColor
사용은 CSS가 로드되지 않는 경우 브라우저에서 사용할 최소 스타일 규칙을 제공합니다. 이렇게 하면 네트워크 불안정에 대한 좋은 방어 스타일이 만들어집니다.
레이아웃
테마 전환 구성요소의 표면 영역이 작으므로 레이아웃에 그리드나 플렉스박스가 필요하지 않습니다. 대신 SVG 위치 지정 및 CSS 변환이 사용됩니다.
스타일
.theme-toggle
스타일
<button>
요소는 아이콘 모양과 스타일의 컨테이너입니다. 이 상위 컨텍스트는 SVG에 전달할 적응형 색상과 크기를 보유합니다.
첫 번째 작업은 버튼을 원으로 만들고 기본 버튼 스타일을 삭제하는 것입니다.
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
}
다음으로 상호작용 스타일을 추가합니다. 마우스 사용자를 위한 커서 스타일을 추가합니다. 빠르게 반응하는 터치 환경을 위해 touch-action: manipulation
추가
iOS가 버튼에 적용하는 반투명 강조 표시를 삭제합니다. 마지막으로 포커스 상태 윤곽선이 요소의 가장자리에서 약간 떨어지도록 합니다.
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
outline-offset: 5px;
}
버튼 내부의 SVG에도 스타일이 필요합니다. SVG는 버튼 크기에 맞아야 하며 시각적 부드러움을 위해 선 끝을 둥글게 처리해야 합니다.
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
outline-offset: 5px;
& > svg {
inline-size: 100%;
block-size: 100%;
stroke-linecap: round;
}
}
hover
미디어 쿼리를 사용한 적응형 크기 조정
아이콘 버튼 크기가 2rem
로 약간 작습니다. 마우스 사용자에게는 괜찮지만 손가락과 같은 거친 포인터에는 어려울 수 있습니다. 마우스 오버 미디어 쿼리를 사용하여 크기 증가를 지정하여 버튼이 여러 터치 크기 가이드라인을 충족하도록 합니다.
.theme-toggle {
--size: 2rem;
…
@media (hover: none) {
--size: 48px;
}
}
태양 및 달 SVG 스타일
버튼은 테마 전환 구성요소의 대화형 측면을 보유하고 SVG는 시각적 측면과 애니메이션 측면을 보유합니다. 여기에서 아이콘을 아름답게 만들고 생동감을 불어넣을 수 있습니다.
밝은 테마

SVG 도형의 중심에서 크기 조절 및 회전 애니메이션이 발생하도록 하려면 transform-origin: center center
를 설정하세요. 여기에서는 버튼에서 제공하는 적응형 색상이 도형에 사용됩니다. 달과 태양은 채우기에 var(--icon-fill)
및 var(--icon-fill-hover)
에 제공된 버튼을 사용하고, 햇살은 획에 변수를 사용합니다.
.sun-and-moon {
& > :is(.moon, .sun, .sun-beams) {
transform-origin: center center;
}
& > :is(.moon, .sun) {
fill: var(--icon-fill);
@nest .theme-toggle:is(:hover, :focus-visible) > & {
fill: var(--icon-fill-hover);
}
}
& > .sun-beams {
stroke: var(--icon-fill);
stroke-width: 2px;
@nest .theme-toggle:is(:hover, :focus-visible) & {
stroke: var(--icon-fill-hover);
}
}
}
어두운 테마

달 스타일에서는 햇살을 삭제하고, 태양 원을 확대하고, 원 마스크를 이동해야 합니다.
.sun-and-moon {
@nest [data-theme="dark"] & {
& > .sun {
transform: scale(1.75);
}
& > .sun-beams {
opacity: 0;
}
& > .moon > circle {
transform: translateX(-7px);
@supports (cx: 1px) {
transform: translateX(0);
cx: 17px;
}
}
}
}
어두운 테마에는 색상 변경이나 전환이 없습니다. 상위 버튼 구성요소는 색상을 소유하며, 색상은 어두운 컨텍스트와 밝은 컨텍스트 내에서 이미 적응형입니다. 전환 정보는 사용자의 동작 환경설정 미디어 쿼리 뒤에 있어야 합니다.
애니메이션
이 시점에서 버튼은 기능적이고 상태가 있지만 전환은 없습니다. 다음 섹션에서는 전환의 방식과 내용을 정의하는 방법을 설명합니다.
미디어 쿼리 공유 및 이징 가져오기
전환과 애니메이션을 사용자의 운영체제 동작 환경설정 뒤에 쉽게 배치할 수 있도록 PostCSS 플러그인 Custom Media를 사용하면 미디어 쿼리 변수를 위한 초안 CSS 사양 구문을 사용할 수 있습니다.
@custom-media --motionOK (prefers-reduced-motion: no-preference);
/* usage example */
@media (--motionOK) {
.sun {
transition: transform .5s var(--ease-elastic-3);
}
}
고유하고 사용하기 쉬운 CSS 이징을 사용하려면 Open Props의 easings 부분을 가져옵니다.
@import "https://unpkg.com/open-props/easings.min.css";
/* usage example */
.sun {
transition: transform .5s var(--ease-elastic-3);
}
태양
태양 전환은 달보다 더 경쾌하며, 바운스 효과가 있는 이징을 사용하여 이 효과를 구현합니다. 햇빛은 회전할 때 약간 튀어야 하고 태양의 중심은 크기가 조정될 때 약간 튀어야 합니다.
기본 (밝은 테마) 스타일은 전환을 정의하고 어두운 테마 스타일은 밝은 테마로의 전환을 위한 맞춤설정을 정의합니다.
.sun-and-moon {
@media (--motionOK) {
& > .sun {
transition: transform .5s var(--ease-elastic-3);
}
& > .sun-beams {
transition:
transform .5s var(--ease-elastic-4),
opacity .5s var(--ease-3)
;
}
@nest [data-theme="dark"] & {
& > .sun {
transform: scale(1.75);
transition-timing-function: var(--ease-3);
transition-duration: .25s;
}
& > .sun-beams {
transform: rotateZ(-25deg);
transition-duration: .15s;
}
}
}
}
Chrome DevTools의 애니메이션 패널에서 애니메이션 전환의 타임라인을 확인할 수 있습니다. 전체 애니메이션, 요소, 이징 타이밍의 지속 시간을 검사할 수 있습니다.


달
달의 밝은 위치와 어두운 위치가 이미 설정되어 있습니다. --motionOK
미디어 쿼리 내부에 전환 스타일을 추가하여 사용자의 동작 환경설정을 준수하면서 달을 생생하게 표현합니다.
지연 시간과 지속 시간은 이 전환을 깔끔하게 만드는 데 매우 중요합니다. 예를 들어 너무 일찍 일식이 일어나면 전환이 조화롭거나 재미있지 않고 혼란스럽게 느껴집니다.
.sun-and-moon {
@media (--motionOK) {
& .moon > circle {
transform: translateX(-7px);
transition: transform .25s var(--ease-out-5);
@supports (cx: 1px) {
transform: translateX(0);
cx: 17px;
transition: cx .25s var(--ease-out-5);
}
}
@nest [data-theme="dark"] & {
& > .moon > circle {
transition-delay: .25s;
transition-duration: .5s;
}
}
}
}


모션 감소 선호
대부분의 GUI 챌린지에서 동작 감소를 선호하는 사용자를 위해 불투명도 크로스 페이드와 같은 애니메이션을 유지하려고 합니다. 하지만 이 구성요소는 즉각적인 상태 변경이 더 적합했습니다.
자바스크립트
이 구성요소에서는 스크린 리더의 ARIA 정보를 관리하는 것부터 로컬 저장소에서 값을 가져오고 설정하는 것까지 JavaScript가 많은 작업을 처리합니다.
페이지 로드 환경
페이지 로드 시 색상 깜박임이 발생하지 않는 것이 중요했습니다. 어두운 색 구성표를 사용하는 사용자가 이 구성요소로 밝은 색을 선호한다고 표시한 후 페이지를 새로고침하면 처음에는 페이지가 어두워지다가 밝은 색으로 깜박입니다.
이를 방지하려면 HTML 속성 data-theme
를 최대한 빨리 설정하기 위해 소량의 차단 JavaScript를 실행해야 했습니다.
<script src="./theme-toggle.js"></script>
이를 위해 문서 <head>
의 일반 <script>
태그가 CSS 또는 <body>
마크업보다 먼저 로드됩니다. 브라우저가 이와 같이 표시되지 않은 스크립트를 발견하면 나머지 HTML보다 먼저 코드를 실행합니다. 이 차단 순간을 적절히 사용하면 기본 CSS가 페이지를 페인트하기 전에 HTML 속성을 설정하여 깜박임이나 색상을 방지할 수 있습니다.
JavaScript는 먼저 로컬 저장소에서 사용자의 환경설정을 확인하고 저장소에서 아무것도 찾지 못하면 시스템 환경설정을 확인합니다.
const storageKey = 'theme-preference'
const getColorPreference = () => {
if (localStorage.getItem(storageKey))
return localStorage.getItem(storageKey)
else
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
다음으로 로컬 스토리지에서 사용자의 환경설정을 설정하는 함수가 파싱됩니다.
const setPreference = () => {
localStorage.setItem(storageKey, theme.value)
reflectPreference()
}
그 뒤에 환경설정으로 문서를 수정하는 함수가 나옵니다.
const reflectPreference = () => {
document.firstElementChild
.setAttribute('data-theme', theme.value)
document
.querySelector('#theme-toggle')
?.setAttribute('aria-label', theme.value)
}
이 시점에서 중요한 점은 HTML 문서 파싱 상태입니다. <head>
태그가 아직 완전히 파싱되지 않았으므로 브라우저가 '#theme-toggle' 버튼을 알지 못합니다. 하지만 브라우저에는 document.firstElementChild
, 즉 <html>
태그가 있습니다. 이 함수는 두 값을 동기화하려고 시도하지만 첫 번째 실행에서는 HTML 태그만 설정할 수 있습니다. querySelector
은 처음에는 아무것도 찾지 못하며 선택적 체이닝 연산자는 찾지 못하고 setAttribute 함수를 호출하려고 할 때 구문 오류가 발생하지 않도록 합니다.
그런 다음 해당 함수 reflectPreference()
가 즉시 호출되어 HTML 문서의 data-theme
속성이 설정됩니다.
reflectPreference()
버튼에는 여전히 속성이 필요하므로 페이지 로드 이벤트를 기다린 후 다음에서 안전하게 쿼리하고 리스너를 추가하고 속성을 설정할 수 있습니다.
window.onload = () => {
// set on load so screen readers can get the latest value on the button
reflectPreference()
// now this script can find and listen for clicks on the control
document
.querySelector('#theme-toggle')
.addEventListener('click', onClick)
}
전환 환경
버튼을 클릭하면 JavaScript 메모리와 문서에서 테마를 바꿔야 합니다. 현재 테마 값을 검사하고 새 상태에 관해 결정해야 합니다. 새 상태가 설정되면 저장하고 문서를 업데이트합니다.
const onClick = () => {
theme.value = theme.value === 'light'
? 'dark'
: 'light'
setPreference()
}
시스템과 동기화
이 테마 스위치의 고유한 점은 시스템 환경설정이 변경될 때 시스템 환경설정과 동기화된다는 것입니다. 페이지와 이 구성요소가 표시된 상태에서 사용자가 시스템 환경설정을 변경하면 사용자가 시스템 전환과 동시에 테마 전환과 상호작용한 것처럼 테마 전환이 새 사용자 환경설정과 일치하도록 변경됩니다.
미디어 쿼리의 변경사항을 수신 대기하는 matchMedia
이벤트와 JavaScript를 사용하여 이를 달성합니다.
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({matches:isDark}) => {
theme.value = isDark ? 'dark' : 'light'
setPreference()
})
결론
이제 제가 어떻게 했는지 아셨으니, 어떻게 하시겠어요? 🙂
다양한 접근 방식을 사용하고 웹에서 빌드하는 모든 방법을 알아보세요. 데모를 만들고 트윗으로 링크를 보내주세요. 아래 커뮤니티 리믹스 섹션에 추가해 드리겠습니다.
커뮤니티 리믹스
- Vue를 사용한 CodePen의 @NathanG
- Codepen의 @ShadowShahriar
- @tomayac을 맞춤 요소로 사용
- 일반 JavaScript를 사용하는 @bramus
- React를 사용하는 @JoshWComeau