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

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

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

デモのボタンのサイズを大きくして見やすくします

動画で確認したい場合は、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 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: 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 システム設定を変更すると、テーマ切り替えの状態が変更されます

まとめ

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

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

コミュニティ リミックス