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

アダプティブで利用しやすいテーマ切り替えコンポーネントを作成する方法の基本的な概要。

この投稿では、ダークモードとライトモードの切り替えコンポーネントを作成する方法について考えを共有します。デモをお試しください

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

動画をご覧になる場合は、この投稿の 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(Scalable Vector Graphic)アイコン

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 プロパティを 12(ビューポート サイズ(24)の半分)に設定することで <circle> を中央に配置し、サイズを設定する 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>

また、mask プロパティは、次に作成する 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>
マスキングの仕組みを示す 3 つの垂直レイヤからなるグラフィック。最上位レイヤは、黒い円が入った白い正方形です。中央のレイヤは太陽のアイコンです。
一番下のレイヤには結果としてラベルが付けられ、一番上のレイヤに黒い円がある切り欠きのある太陽のアイコンが表示されています。

SVG を使用したマスクは強力で、白と黒の色を使用して、別のグラフィックの一部を削除または含めることができます。SVG マスクを使用すると、太陽のアイコンが月の <circle> シェイプに欠けてしまいます。これは、円形のシェイプをマスク領域の内外に移動するだけです。

CSS が読み込まれない場合はどうなりますか?

中に太陽のアイコンがある、単純なブラウザボタンのスクリーンショット。

CSS が読み込まれなかったかのように SVG をテストし、結果が極端に大きくなったり、レイアウトの問題が生じたりしていないか確認することをおすすめします。SVG 上のインラインの高さと幅の属性と、currentColor を使用すると、CSS が読み込まれない場合にブラウザが使用する最小限のスタイルルールが設定されます。これにより、ネットワークの乱れに対する優れた防御スタイルが実現します。

Layout

テーマ切り替えコンポーネントは表面積が小さいため、レイアウトにグリッドや 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;
      }
    }
  }
}

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

アニメーション

ボタンは機能的かつステートフルである必要がありますが、この時点では遷移はありません。以下のセクションでは、遷移をどのように定義するかについて説明します。

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

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

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

月のライトとダークの位置はすでに設定済みです。--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 情報の管理から、ローカル ストレージからの値の取得や設定まで、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 のシステム設定を変更すると、テーマの切り替えの状態が変更される

おわりに

私のやり方がわかったところで、どうしたらいいですか? 🙂?

多様なアプローチと、ウェブでの構築方法を学んでいきましょう。 デモを作成してツイートのリンクをお願いします。下のコミュニティ リミックス セクションに追加します。

コミュニティのリミックス