테마 스위치 구성요소 빌드

적응형 및 접근성이 뛰어난 테마 전환 구성요소를 빌드하는 방법에 관한 기본 개요

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

데모 버튼 크기가 커져 더 쉽게 볼 수 있음

동영상을 선호하는 경우 이 게시물의 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-labelaria-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에 편리하게 도형이 있는 원과 선으로 구성됩니다. <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를 가리키고 마지막으로 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>
마스킹의 작동 방식을 보여주는 세 개의 세로 레이어가 있는 그래픽 최상위 레이어는 검은색 원이 있는 흰색 정사각형입니다. 중간 레이어는 태양 아이콘입니다.
하단 레이어에는 결과라는 라벨이 지정되어 있으며, 상단 레이어의 검은색 원이 있는 부분이 잘린 태양 아이콘이 표시됩니다.

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: 1px) {
        transform: translateX(0);
        cx: 17px;
      }
    }
  }
}

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

애니메이션

버튼은 기능하고 스테이트풀(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 Propseasings 부분을 가져옵니다.

@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 이벤트를 사용하여 이를 실행합니다.

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

결론

이제 제가 어떻게 했는지 알았으니 어떻게 하시겠어요? 🙂

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

커뮤니티 리믹스