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

アダプティブでユーザー補助に優れたテーマ切り替えコンポーネントを構築する方法の基本的な概要。

この記事では、ダークモードとライトモードを切り替えるコンポーネントを構築する方法について説明します。デモをお試しください

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

動画でご覧になりたい場合は、こちらの 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 が何になったかに応じて「ライト」または「ダーク」とアナウンスします。

Scalable Vector Graphics(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 には、これらの形状を簡単に作成できる機能があります。cx プロパティと cy プロパティをビューポート サイズ(24)の半分である 12 に設定して <circle> を中央に配置し、サイズを設定する半径(r)を 6 に設定します。

<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 ではなく、各行の stroke が設定されています。線と円の形を組み合わせることで、光線が伸びる太陽のイラストが作成されます。

太陽(明るい)と月(暗い)の間のシームレスな切り替えを表現するため、月は 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 が読み込まれない場合にブラウザが使用する最小限のスタイルルールが提供されます。これにより、ネットワークの不安定さに対する優れた防御スタイルが実現します。

レイアウト

テーマ切り替えコンポーネントは表面積が小さいため、レイアウトにグリッドやフレックスボックスは必要ありません。代わりに、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 の [アニメーション] パネルには、アニメーションの切り替えのタイムラインが表示されます。アニメーション全体の長さ、要素、イージングのタイミングを調べることができます。

ライトモードからダークモードへの切り替え
ダークモードからライトモードへの切り替え

月の明るい位置と暗い位置はすでに設定されています。--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 のシステム設定を変更すると、テーマの切り替え状態が変更される

まとめ

私がどのように行ったかをご理解いただけたかと思います。では、あなたならどのようにしますか?🙂

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

コミュニティ リミックス