テーマ切り替えコンポーネントの作成

適応性とユーザー補助に優れたテーマ切り替えコンポーネントを作成する方法の概要。

この記事では、ダークモードとライトモードの切り替えコンポーネントを作成する方法について考えたいと思います。デモを試す

デモボタンのサイズを大きくして視認性を高めました

動画で確認したい場合は、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>

SVG 要素に aria-hidden が追加されました。これにより、スクリーン リーダーは、プレゼンテーション用としてマークされているため、この要素を無視できます。これは、ボタン内のアイコンなどの視覚的な装飾に適しています。要素に必須の viewBox 属性に加えて、高さと幅を追加します。これは、画像にインラインサイズを適用する理由と同様です。

太陽光

日光がフェードアウトした太陽のアイコンと、中央の円を指すホットピンクの矢印。

太陽のグラフィックは、SVG に便利なシェイプがある円と線で構成されています。<circle> は、cx プロパティと cy プロパティをビューポートのサイズ(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>

今回は、塗りつぶしの値が 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 のインライン height 属性と width 属性に加えて 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;
      }
    }
  }
}

ダークモードでは色の変化や遷移はありません。親ボタン コンポーネントが色を所有し、ダーク コンテキストとライト コンテキスト内ですでに適応しています。遷移情報は、ユーザーのモーション設定メディアクエリの後に配置する必要があります。

アニメーション

この時点では、ボタンは機能し、ステートフルである必要がありますが、遷移は必要ありません。以降のセクションでは、遷移の方法内容を定義します。

メディアクエリの共有とイージングのインポート

ユーザーのオペレーティング システムのモーション設定にトランジションとアニメーションを簡単に適用できるように、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 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 の [Animation] パネルには、アニメーション遷移のタイムラインが表示されます。アニメーション全体、要素、イージング タイミングの合計時間を確認できます。

ライトモードからダークモードへの遷移
ダークモードからライトモードへの遷移

月明かりと暗闇の位置はすでに設定されています。--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 チャレンジでは、モーションを抑えたいユーザーのために、不透明度のクロスフェードなどのアニメーションを残すようにしています。ただし、このコンポーネントは、状態の即時変更でより適切に機能しました。

JavaScript

このコンポーネントでは、スクリーン リーダーの 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()
}

システムとの同期

このテーマ切り替えに固有なのは、システム設定の変更に応じて同期される点です。ページとこのコンポーネントが表示されているときにユーザーがシステム設定を変更すると、テーマ切り替えは、ユーザーがシステム切り替えと同時にテーマ切り替えを操作したかのように、新しいユーザー設定に合わせて変更されます。

これは、JavaScript と、メディアクエリの変更をリッスンする matchMedia イベントを使用して実現できます。

window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
macOS システム設定を変更すると、テーマ切り替えの状態が変更されます

まとめ

私の方法をご覧になったところで、あなたならどうしますか?

アプローチを多様化し、ウェブで構築するすべての方法を学びましょう。デモを作成して、ツイートしてください。リンクを送信していただければ、下のコミュニティ リミックスのセクションに追加します。

コミュニティ リミックス