🌗

Tailwind CSS を使った ダークモード実装の効率的なアプローチ

2023/05/15に公開

はじめに

現在、海外のプロダクトのほとんどがダークモードに対応しており、その普及は加速しています。ダークモード対応は単なるデザイン要素にとどまらず、プロダクトの品質や開発力の高さ、アクセシビリティへの配慮を示す効果もあります。

日本ではまだまだ普及が遅れているダークモード対応ですが、効率的な実装を行えば実は少ない工数でダークモードに対応できます。この記事では Tailwind CSS におけるダークモードの効率的な実装について解説します。

本記事は shadcn/uiTailwind UI のコードを参考にしており、著者の属人的アプローチではありません。

結論

CSS 変数と Tailwind CSS の色拡張を使うことでテーマごとの配色を一元管理しつつ、Tailwind CSS の直感的な DX も維持できます。

https://tailwindcss.com/docs/customizing-colors#using-css-variables

https://ui.shadcn.com/docs/theming

Demo

実装例を用意しました。 csstailwind.config.js を確認してみてください。

https://play.tailwindcss.com/luYvml1ecd

実装手順

https://snappify.com/view/8d426d44-f485-4c62-9bc8-8efe23f558ea

まずは globals.css で CSS 変数を定義します。

globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%; // ベースの背景色
    --foreground: 222.2 47.4% 11.2%; // ベースの文字色

    --muted: 210 40% 96.1%; // 補足要素の背景色
    --muted-foreground: 215.4 16.3% 46.9%; // 補足要素の文字色

    --card: 0 0% 100%; // カードの背景
    --card-foreground: 222.2 47.4% 11.2%; // カードの文字色
  }

  // ダークモード時の配色
  .dark {
    --background: 224 71% 4%;
    --foreground: 213 31% 91%;

    --muted: 223 47% 11%;
    --muted-foreground: 215.4 16.3% 56.9%;

    --card: 224 71% 4%;
    --card-foreground: 213 31% 91%;
  }
}

@layer base {
  body {
    @apply bg-background text-foreground;
  }
}

次に tailwind.config.js でカスタムカラーに変数を割り当てます。

tailwind.config.js
module.exports = {
  darkMode: 'class',
  content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
  theme: {
    extend: {
      colors: {
        // 全体的な背景色
        background: "hsl(var(--background))",

	// 全体的な文字色
        foreground: "hsl(var(--foreground))",

	// サブ的要素の背景色、文字色
        muted: {
	  // 背景色
          DEFAULT: "hsl(var(--muted))",

	  // 文字色
          foreground: "hsl(var(--muted-foreground))",
        },
      },
    },
  },
}

これで配色設計は完了です。実装時は以下のように使用します。

const demo = (
  <div>
    <h2>見出しテキスト</h2>
    <p className="text-muted-foreground">サブテキスト</p>
  </div>
);

複雑に感じるかもしれませんが構造はシンプルです。

  1. globals.css で配色の CSS 変数を定義
  2. tailwind.config.js でカスタムカラーに CSS 変数を紐づける
  3. 実装時にカスタムカラーを使用

これにより globals.css の CSS 変数を変更することでテーマごとの配色を一元管理できます。さらに、text-muted-foreground/50 等の透過調整 や sm:text-muted-foreground などの装飾子も使用可能になります。

また、カスタムカラーとして登録されるので Tailwind CSS Intellisense で色の指定の競合をハイライトしてれたり、色の候補としてラインナップしてくれるようになります。

注意点として、 text-gray/50 などのように色の透過度を調整可能にするために色はカラーチャネルのみで定義する必要があります。 rgbhls のフォーマットに応じて tailwind.config.js 側でカスタムカラーと紐づける際の記述が変わる点にも注意しましょう。

/* カスタムカラー定義時: rgb(var(--primary)) */
--primary: 255 115 179;

/* カスタムカラー定義時: hsl(var(--primary)) */
--primary: 198deg 93% 60%;

/* カスタムカラー定義時: rgba(var(--primary)) */
--primary: 255, 115, 179;

/* 🚫 以下の指定はNG(透過調整ができないため) */
--color-primary: rgb(255, 115, 179);
--color-primary: #fafafa;
--color-primary: black;

カラーチャネルは Hex to RGB Color Converterを使ったり、Visual Studio Code に hex(#fff)を記述した上で色アイコンをクリックすることで効率的に指定できます。

次のセクションで他のアプローチが抱える課題について解説します。

おまけ: Next.js でのダークモード環境設定

next-themes を使うことでダークモードの環境が簡単に構築できます。

  1. Next.js の環境構築(CLI で Tailwind CSS の使用を yes にする)
  2. Tailwind CSS の設定でダークモードを 'class' 制御に設定する
  3. next-themes のインストール
  4. next-themes の Provider を追加

なお、Next.js の App RouterPages Router でアプローチが異なります。これから新規プロジェクトを始める場合公式が推奨する App Router を選択しましょう。

Provider に attribute="class" を加えて Tailwind CSS のダークテーマ制御に対応します。さらに enableColorScheme={false} を加えて完成です。これによりブラウザ標準のダークテーマが無効化されるので、自分のカスタムカラーと競合しなくなります。

<ThemeProvider attribute="class" enableColorScheme={false}>
  {children}
</ThemeProvider>

以上で環境構築は終了です。ダークモード切り替えなどはnext-themes を参照してください。

ダークモード実装が抱える課題

他のアプローチで実装した場合の課題をまとめました。

dark 装飾子を多様することの課題

Tailwind CSS でのダークテーマ対応は基本的に dark 修飾子をつけて対応します。たとえば light モードの場合は文字を黒、 dark モードの場合文字を白にしたいとします。

<p className="text-black dark:text-white">テキスト</p>

これは Tailwind CSS のドキュメントで案内されるアプローチですが、これには以下のような課題があります。

  • プロダクトの配色に一貫性を持たせるためにすべてのテキストに対し繰り返し同じ記述が必要になる
  • ダークモードの文字色を変更する場合、全体的にコードを更新する必要がある
  • 開発メンバー全員がダークモード時の正しい配色を把握する必要がある

以上の理由から、このアプローチでプロダクト全体のダークモード対応を行うのは避けましょう。マークアップのいたるところに dark: をつけまくるアプローチはメンテナンス性が低く、現実的ではありません。

コンポーネント化することの課題

次に思いつくのがコンポーネント化です。Tailwind CSS 公式ドキュメントでも繰り返しの記述を避ける手段として推奨されているアプローチです。例としてダークモードに対応する Text コンポーネントを作成します。

// コンポーネント
export default function Text({ children }) {
  return <p className="text-black dark:text-white">{children}</p>;
}

// 使用
const foo = <Text>ダークモードに対応するテキスト</Text>;

Mantine などの UI フレームワークを使ったことがある人はこのアプローチが馴染むと思いますが、このアプローチは以下の課題を抱えています。

  • spanli などにも対応する場合、コンポーネントの設計が複雑になる
  • <p>タグの中に<p>タグのような事故が生まれやすい
  • 文字色の透過を制御することができない(text-black/10 のような透過制御)
  • マークアップとスタイルが共同体になるため、 sm: 等による Tailwind CSS のエコシステムと切り離される
  • 途中で配色を変更する場合、コンポーネントをまたいで一斉更新が必要になる

コンポーネント化すべきはあくまで ButtonModalCard のように機能や一定のまとまりをもつものにすべきで、単に配色を再利用するために Text やその他のコンポーネントを作成し、複雑な props を持たせるのはオーバーです。

カスタムスタイルを作ることの課題

Tailwind CSS のドキュメントでは繰り返しの記述を避けるために、カスタムスタイルを追加するアプローチも紹介されています。

https://tailwindcss.com/docs/adding-custom-styles#using-css-and-layer

globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .sub-text {
    @apply text-gray-300 dark:text-gray-700;
  }
}
<p className="sub-text">モードによって色が変わるサブテキスト</p>

このアプローチは筋がいいように思えますが、実は大きな課題があります。

まず、モードによって変わるのは基本的には なので、 冒頭のアプローチのように Tailwind CSS にはカスタムカラーとして登録すべきです。しかしこのアプローチではカスタムコンポーネントとして追加しています。そのため、 Tailwind CSS Intellisenseで競合をハイライトしてくれません。

以下のマークアップに対し何も警告が出ないので、実装者は最終的にどの色が適用されるか想像できずバグの元になります。

<p cassName="sub-text text-yellow-500">どの色になるのか一見予測できない</p>

Tailwind CSS Intellisense は競合する色指定に対し警告を表示するので、本来であれば実装者は間違いに気づけます。

また、カスタムスタイルが増えると開発メンバーがそれらをキャッチアップするコストが発生します。中で何が起きて、他とどう競合するのか推測が難しい sub-buttonsub-card 的なクラスが量産された場合、メンテナンス性が著しく下がってしまいます。

以上を踏まえ、ダークモードの色を制御するためにカスタムスタイルを追加するのは避けるべきです。

まとめ

以上を踏まえ、冒頭で紹介した CSS 変数と カスタムカラーを使うアプローチが最適解だという結論に至りました。このアプローチは最近海外で注目を集めている shadcn/ui で知りました。 shadcn/ui をつかうとよくある変数の定義とカスタムカラーの紐付けをプロジェクトに反映することができるので興味のある方は試してみてください。

// 色変数や tailwind.config.js をセットしてくれる
npx shadcn-ui init

あるいはソースコードから適宜流用することもできます。

Tailwind CSS 公式のアプローチは?

Tailwind CSS 公式のサンプルコード、テンプレート集 として Tailwind UI があります。コードは有料で閲覧可能です。その中ではどのようにしてダークモード対応が行われいるのか調べましたが、パーツ単位の部分はそもそもダークテーマとライトテーマを別にしてリストされているため、 dark: による切り分けはなされていませんでした。

Tailwind UI/Template の中でテーマ切り替えがあるものがあり、その中では

prose dark:prose-revert というアプローチと、 dark: をつけまくるアプローチが取られていました。あくまでミニマムなテンプレートなのでダークテーマ対応は最小限かつシンプルにしたかったようで、実用的なアプローチが端折られてたのはやや残念でした。(あるいは Tailwind CSS Way 的には dark: つけまくれってことなのかも...)

prose dark:prose-revert についてはブログ記事っぽい文章群をまとめてスタイルする @tailwindcss/typography プラグインの拡張クラスなのですが、これによりえいやあでまとめて色をよしなに反転してくれます。カスタマイズは可能ですがあくまで記事というスコープ用のものなので、アプリケーション全体を prose dark:prose-revert で終わらせるのは厳しいと思います。

あとがき

ダークモードの配色をよしなに提案してくれる配色ジェネレータがあったり、今後は AI が配色考えてくれたりもしそうなので、日本でもダークモードが増えていくと思います。

これから Web アプリを作る方はぜひダークモード対応を検討してみてください。

Deer

Discussion