UPSIDER Tech Blog

Tailwind CSSで動的なテーマカラーを扱う方法

こんにちは、UPSIDERでエンジニアをしている太田です(@Hide55832241)。
私たちが開発する支払い.comをはじめUPSIDERのプロダクトではTailwind CSSを使用しているものがあります。
その中で動的に色を扱いたいケースがあったため紹介します。

※ Tailwind v3の話をします。記事後半でv4について少し触れます。

背景とユースケース

1つのプロダクトでユーザーごとにテーマカラーを変えたい。
たとえばコミュニティサイトでプロフィールページのキーカラーを自由に設定させたい場面を想定します。
デフォルトは灰みがかった青紫です。

しかし全員この色では少し退屈なので、ユーザーごとに自由に色を設定できるようにします。
プレビュー画面を用意し、以下のように自由に色を選択しデータベースに保存します。

例えばくすんだ赤みのある茶色を選択するとページ全体の印象が変わります。

このようにユーザーごとにテーマカラーを変更可能とすることで、オリジナリティを出せるようになります。
以上のようにデータベースに保存した色をAPI経由で使用することを想定します。
ユーザー毎でなくても何らかの理由でページやコンポーネント単位でテーマカラーを変える場合も同様です。

うまくいかない方法

Arbitrary values

v3.tailwindcss.com

<div className={'bg-[#f00]'}>

Tailwindで設定ファイルに定義していない値を扱うにはArbitrary valuesを使うことができます。
しかしArbitrary valuesはビルド時にclassを抽出する仕組み上、ビルド時に静的に値が確定している必要があります。
文字列リテラル内で変数を展開するような使い方をするとスタイルが適用されません。

値がビルド時に静的に確定している場合、スタイルが反映される

return (
  <div className={'bg-[#f8f6f6]'}>
    <div className={'bg-[#e3d9d9]'} />
    <div className={'bg-[#946b6b]'} />
  </div>
)

値がビルド時に静的に確定していない場合、スタイルが反映されない

const fetchedValue = useFetchColor()

return (
  <div className={`bg-[${fetchedValue.surface['1']}]`}>
    <div className={`bg-[${fetchedValue.surface['2']}]`} />
    <div className={`bg-[${fetchedValue.fill.base}]`} />
  </div>
)

解決策1 - Tailwindのユーティリティクラスを使わずStyle属性に直書きする

動的な値を扱う場合、無理にTailwindのユーティリティクラスを使う必要はないかもしれません。
style属性に直書きすることで問題なく反映されます。

const fetchedValue = useFetchColor()

return (
  <div style={{ background: fetchedValue.surface['1'] }}>
    <div style={{ background: fetchedValue.surface['2'] }} />
    <div style={{ background: fetchedValue.fill.base }} />
  </div>
)

課題

しかしコード量が多くなってくると以下のような課題が出てきます

  • Tailwindのclass指定とのstyle属性の併用が多発
  • style属性の直書きが多発
const fetchedValue = useFetchColor()

return (
  <div>
    <div style={{ background: fetchedValue.surface['1'] }}>
      <div style={{ background: fetchedValue.surface['2'] }} />
      <div style={{ background: fetchedValue.fill.base }} />
    </div>

    {/* style直書きだらけになる */}
    <div style={{ background: fetchedValue.surface['1'] }}>
      {/* classNameとstyleを併用する必要がある */}
      <div
        className={'rounded-full'}
        style={{ background: fetchedValue.fill.base }}
      />
    </div>
  </div>
)

またあまりレスポンシブで色を変更したいことはないかもしれませんが、レスポンシブや擬似クラス、ダークモードへの対応に手間がかかります。
このようにstyle属性の直書きには課題があるため、Tailwindを使用し動的な色を扱う方法について説明します。

解決策2 - TailwindでCSS変数を使う

Tailwindの設定ファイルの色の定義をCSS変数を使い指定します。

export default {
  theme: {
    colors: {
      primary: {
        'fill-base': 'var(--color-primary-fill-base, #74738c)',
        'surface-1': 'var(--color-primary-surface-1, #dbdbe1)',
        'surface-2': 'var(--color-primary-surface-2, #f7f7f8)',
      },
    },
  },
}

色を変更したい範囲でCSS変数を上書きすることで、任意の色を扱えるようになります。

const fetchedValue = useFetchColor()

return (
  <div style={{
    // CSS変数を上書きする
    '--color-primary-fill-base': fetchedValue.fill.base,
    '--color-primary-surface-1': fetchedValue.surface['1'],
    '--color-primary-surface-2': fetchedValue.surface['2'],
  }}>
    <div className={'bg-primary-surface-1'}>
      <div className={'bg-primary-surface-2'} />
      <div className={'bg-primary-fill-base'} />
    </div>

    <div className={'bg-primary-surface-1'}>
      <div className={'rounded-full bg-primary-fill-base'} />
    </div>
  </div>
)

これにより通常通りTailwindを扱うのと同じ感覚で、ビルド時に確定しない色を扱うことができるようになります。
レスポンシブや擬似クラスへの対応も簡単にできます。

TailwindでCSS変数を使用し、値を上書きする方法は過去に以下の記事でも紹介しています。

zenn.dev

相対的に色を定義する

少し本題から逸脱しますが、相対的に色を定義する方法について触れます。
この後のTailwind v4の話にもつながります。
先程の例では以下のように3つのCSS変数を上書きしていました。
しかしこの方法だと背景、ボーダー、テキスト、それぞれに対するホバー時の色など、実際にはもっとたくさんのCSS変数を上書きする必要が出てきます。

  <div style={{
    // CSS変数を上書きする
    '--bg-primary-fill-base': fetchedValue.fill.base,
    '--bg-primary-surface-1': fetchedValue.surface['1'],
    '--bg-primary-surface-2': fetchedValue.surface['2'],
  }}>

この問題は以下のようにRelative Color Syntaxを使用することで解決できます。

export default {
  theme: {
    colors: {
      primary: {
        'fill-base': 'var(--color-primary-fill-base, #74738c)',
        'surface-1': 'hsl(from var(--color-primary-fill-base) h s calc(l + 47))',
        'surface-2': 'hsl(from var(--color-primary-fill-base) h s calc(l + 37))',
      },
    },
  },
}
<div style={{
  // 参照元の色を変更すると、参照先のCSS変数の色も変更される
  '--bg-primary-fill-base': fetchedValue.fill.base,
}}>

※ Relative Color Syntaxのブラウザサポート状況は Newly available (Baseline 2024) です。現時点での使用には注意が必要です。

Tailwind v4での変更点と注意事項

Tailwind v3ではテーマの定義をjsファイルで行いましたが、Tailwind v4ではテーマの定義をCSS変数で行うようになりました。

@theme {
  --color-primary-fill-base: #74738c;
  --color-primary-surface-1: #dbdbe1;
  --color-primary-surface-2: #f7f7f8;
}

はじめからCSS変数が定義されているので簡単に上書きできます。
style属性等でCSS変数を上書きするだけでTailwind v3と同じように上書きできます。

  <div style={{
    // CSS変数を上書きする
    '--color-primary-fill-base': fetchedValue.fill.base,
    '--color-primary-surface-1': fetchedValue.surface['1'],
    '--color-primary-surface-2': fetchedValue.surface['2'],
  }}>

CSS変数から他のCSS変数の参照

しかし上記の方法ではCSS変数から別のCSS変数を参照する際に問題が発生します。
以下のように --color-primary-fill-base--color-primary-surface-1 など他のCSS変数から参照するようにします。

@theme {
  --color-primary-fill-base: #74738c;
  --color-primary-surface-1: hsl(from var(--color-primary-fill-base) h s calc(l + 47));
  --color-primary-surface-2: hsl(from var(--color-primary-fill-base) h s calc(l + 37));
}

そして --color-primary-fill-base を上書きします。

  <div style={{
    '--color-primary-fill-base': '#946b6b',
  }}>

すると結果は、以下のように赤枠で囲んだ部分など --color-primary-fill-base の色は変更されたものの、それを参照している --color-primary-surface-1--color-primary-surface-2 の色は変更されていません。

これを解決するには @theme inline を使用する必要があります。
@theme inline を使用することで参照先のCSS変数の値が変更されると、参照元CSS変数の値も再評価されます。

@theme {
  --color-primary-fill-base: #74738c;
}

@theme inline {
  --color-primary-surface-1: hsl(from var(--color-primary-fill-base) h s calc(l + 47));
  --color-primary-surface-2: hsl(from var(--color-primary-fill-base) h s calc(l + 37));
}

--color-primary-surface-1--color-primary-surface-2 の色も変更されるようになります。

tailwindcss.com

所感

Tailwind CSS はTypeScriptのような直接的な型安全性こそないものの、デザイントークンの一元管理とレスポンシブや疑似要素などの実装をシンプルにすると感じています。
私たちのプロダクトでは3年以上、個人的にも5年以上Tailwindを使用したプロダクトを本番運用してきて、大きなトラブルや負債なく運用できていることにとても信頼をしています。
Tailwindを使用しているが故に実装できないデザインもよほど特殊なもの以外はほとんどなく、制約が少ないことも使用を続けたいと思える理由のひとつです。
制約は少ないと感じつつも今回紹介したような動的な値を扱うことはできないと思っていました。
しかし今回紹介した内容により扱うことができたことはまたひとつ制約が少なくなったように感じています。
v4ではCSS変数でテーマを定義することになったことにより、JavaScriptで計算した値をテーマに使用できなくなり不便に思う一方、進化しているCSSを直に使うことができるようになったことは魅力に感じています。
ただし今回紹介した @theme@theme inline の使い分けが必要であることなど、イレギュラーな上書きのために使い分けを行うことが正しいのか悩みが増えそうな印象も受けています。

We Are Hiring !!

支払い.comをはじめUPSIDERでは現在積極採用をしています。 ぜひお気軽にご応募ください。

herp.careers

カジュアル面談はこちら!

herp.careers

Culture Deckはこちら📣

speakerdeck.com