테마 스위치 구성요소 빌드

적응형 테마 전환 구성요소를 빌드하는 방법에 관한 기본적인 개요입니다.

이 게시물에서는 어두운 테마와 밝은 테마 스위치 구성요소를 빌드하는 방법에 관한 생각을 공유하고자 합니다. 데모 사용해 보기

데모 버튼 크기가 커짐

동영상을 선호한다면 이 게시물의 YouTube 버전을 참조하세요.

개요

웹사이트에서는 시스템 환경설정에 전적으로 의존하는 대신 색 구성표를 제어하는 설정을 제공할 수 있습니다. 즉, 사용자가 시스템 환경설정이 아닌 다른 모드로 탐색할 수 있습니다. 예를 들어 사용자의 시스템은 밝은 테마이지만 사용자는 웹사이트를 어두운 테마로 표시하는 것을 선호합니다.

이 기능을 빌드할 때 웹 엔지니어링에서 고려해야 할 몇 가지 사항이 있습니다. 예를 들어 페이지 색상 플래시를 방지하기 위해 브라우저에서 환경설정을 최대한 빨리 인식해야 하며, 컨트롤은 먼저 시스템과 동기화한 다음 클라이언트 측에서 저장된 예외를 허용해야 합니다.

JavaScript 페이지 로드 및 문서 상호작용 이벤트의 미리보기를 보여주는 다이어그램으로 테마를 설정하는 데 4가지 경로가 있음을 전반적으로 보여줍니다.

마크업

전환에는 <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가 어떻게 되었는지에 따라 '밝음' 또는 '어두움'을 알립니다.

Scalable Vector Graph (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에서 쉽게 모양을 지정할 수 있는 원과 선으로 구성됩니다. <circle>cxcy 속성을 표시 영역 크기의 절반 (24)의 절반인 12로 설정한 다음 6의 반경 (r)을 지정하여 중앙에 배치됩니다.

<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를 가리키며 이 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가 아닌 각 선의 이 설정됩니다. 선과 원 모양은 기둥이 있는 멋진 태양을 만듭니다.

빛 (태양)과 어두운(달) 사이를 매끄럽게 전환하는 것처럼 보이게 하기 위해 달은 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>
마스킹의 작동 방식을 보여주는 수직 레이어 3개가 있는 그래픽 상단 레이어는 검은색 원이 있는 흰색 정사각형입니다. 중간 레이어는 태양 아이콘입니다.
하단 레이어에는 결과로 라벨이 지정되며, 상단 레이어의 검은색 원이 있는 곳에 컷아웃이 있는 태양 아이콘이 표시됩니다.

SVG를 사용한 마스크는 강력하므로 흰색과 검은색 색상이 다른 그래픽의 일부를 삭제하거나 포함할 수 있습니다. 태양 아이콘이 SVG 마스크가 있는 달 <circle> 모양으로 가려지며, 마스크 영역 안팎으로 원 모양을 이동하기만 하면 됩니다.

CSS가 로드되지 않으면 어떻게 되나요?

안에 태양 아이콘이 있는 일반 브라우저 버튼의 스크린샷

CSS가 로드되지 않은 것처럼 SVG를 테스트하여 결과가 너무 크지 않거나 레이아웃 문제를 일으키는지 확인하는 것이 좋습니다. SVG의 인라인 높이 및 너비 속성과 currentColor의 사용은 CSS가 로드되지 않는 경우 브라우저에서 사용할 최소한의 스타일 규칙을 제공합니다. 이렇게 하면 네트워크 난류에 효과적으로 방어할 수 있습니다.

레이아웃

테마 스위치 구성요소에는 노출 영역이 적으므로 레이아웃에 그리드나 Flexbox가 필요하지 않습니다. 대신 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 내부에는 시각적 및 애니메이션 측면이 저장됩니다. 아이콘을 아름답고 생생하게 만들 수 있는 곳입니다.

밝은 테마

ALT_TEXT_HERE

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);
    }
  }
}

어두운 테마

ALT_TEXT_HERE

달 스타일에서는 직사광선을 제거하고 태양원을 확장하고 원 마스크를 이동해야 합니다.

.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }

    & > .sun-beams {
      opacity: 0;
    }

    & > .moon > circle {
      transform: translateX(-7px);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
      }
    }
  }
}

어두운 테마에는 색상 변경이나 전환이 없습니다. 상위 버튼 구성요소는 색상을 소유하며, 색상은 어둡고 밝은 컨텍스트 내에서 이미 적응형으로 적용됩니다. 전환 정보는 사용자의 모션 환경설정 미디어 쿼리 뒤에 있어야 합니다.

애니메이션

버튼은 정상적으로 작동하고 스테이트풀(Stateful)이어야 하지만 이 시점에서 전환이 없어야 합니다. 다음 섹션에서는 전환 방법대상을 모두 정의합니다.

미디어 쿼리 공유 및 이징 가져오기

사용자의 운영체제 모션 환경설정 뒤에 전환 및 애니메이션을 쉽게 배치할 수 있도록 PostCSS 플러그인 맞춤 미디어를 사용하면 미디어 쿼리 변수에 대한 초안 CSS 사양 구문을 사용할 수 있습니다.

@custom-media --motionOK (prefers-reduced-motion: no-preference);

/* usage example */
@media (--motionOK) {
  .sun {
    transition: transform .5s var(--ease-elastic-3);
  }
}

고유하고 사용하기 쉬운 CSS 이징을 위해 Open Props이징 부분을 가져옵니다.

@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: 1) {
        transform: translateX(0);
        cx: 17;
        transition: cx .25s var(--ease-out-5);
      }
    }

    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}
밝은 색상에서 어두운 테마로 전환
어두운 곳에서 밝은 테마로 전환

움직임이 적은 것을 선호함

대부분의 GUI 챌린지에서는 낮은 모션을 선호하는 사용자를 위해 불투명도 크로스 페이드와 같은 일부 애니메이션을 유지하려고 합니다. 그러나 이 구성요소는 즉각적인 상태 변경에서 더 좋아졌습니다.

JavaScript

이 구성요소에는 스크린 리더의 ARIA 정보 관리부터 로컬 저장소에서 값을 가져오고 설정하는 작업까지 다양한 작업이 있습니다.

페이지 로드 환경

페이지 로드 시 색 번짐이 발생하지 않도록 해야 했습니다. 어두운 색 구성표를 사용하는 사용자가 이 구성요소를 사용하여 밝은색을 선호한다고 밝힌 후 페이지를 다시 로드하면 처음에는 페이지가 어두워졌다가 밝게 깜박입니다. 이를 방지하기 위해서는 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' 버튼에 관해 알지 못합니다. 하지만 브라우저에는 <html> 태그라고도 하는 document.firstElementChild가 있습니다. 함수는 두 가지를 모두 동기화 상태로 유지하려고 시도하지만 처음 실행할 때 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()
}

시스템과 동기화

이 테마 스위치의 고유한 특징은 시스템 환경설정이 변경될 때 시스템 환경설정과의 동기화입니다. 페이지가 표시되고 이 구성요소가 표시되는 동안 사용자가 시스템 환경설정을 변경하면 테마 스위치는 새 사용자 환경설정에 맞게 변경됩니다. 즉, 사용자가 테마 스위치와 동시에 시스템 전환을 실행한 것처럼 테마 스위치가 변경됩니다.

JavaScript와 미디어 쿼리의 변경사항을 수신 대기하는 matchMedia 이벤트를 사용하면 됩니다.

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
MacOS 시스템 환경설정을 변경하면 테마 전환 상태가 변경됨

결론

이제 제가 어떻게 했는지 알았으니 어떻게 되세요?‽ 🙂

접근 방식을 다양화하고 웹에서 빌드하는 모든 방법을 알아보겠습니다. 데모를 만들고 링크를 트윗해 주세요. 그러면 아래의 커뮤니티 리믹스 섹션에 추가하겠습니다.

커뮤니티 리믹스