Tailwind CSS実践入門 ~まず作ってから、あとで共通化する

Tailwind CSS実践入門
第4章 Tailwind CSSとデザイン
─⁠─より良いデザインのためにCSSはどうあるべきか

これまでの章では、Tailwindの基礎や具体的な使い方について解説しました。ここからは発展編として、デザインとCSSの関係性や、CSS設計におけるTailwindの意味について考察していきます。

ユーティリティファーストは実際のデザインプロセスに近いアプローチ ─⁠─コンポーネントはあとからできる

ユーティリティファーストなワークフローでは、最初はユーティリティで作って、コンポーネントはあとから抽出する、ということを第1章で述べました。これは、従来のやり方とは逆の流れです。従来は、新たにスタイリングするためには、まず新たなコンポーネントを作成する必要があります。つまり、コンポーネントファーストです。

コンポーネントファーストのワークフローでは、先にコンポーネントという枠組みを決めてから実際の中身を作ります。一方、ユーティリティファーストでは、先に実際の中身を作ってから、共通化すべきコンポーネントはそのあとで見い出される、という順番になります。コンポーネントファーストは全体から部分に向かい、ユーティリティファーストは部分から全体に向かうという意味で、これらはトップダウンとボトムアップの二項対立の関係にあるととらえることができます。

ここで一度CSSから離れて、Figmaなどのデザインツールを使ったデザインのプロセスについて考えてみると、ユーティリティファーストはより実際のデザイン作業に近いことがわかります。というのも実際のデザイン作業においては、あらかじめ必要なコンポーネントを決めてコンポーネントありきでデザインしているわけでは必ずしもないでしょう。どのようなコンポーネントができるかは、実際に作ってみるまでわからないのです。

そして、できあがったページのうち、どこからどこまでが1つのコンポーネントかという境界も、時にあいまいなものです。何かしらの要素が集まったページを作ったからといって、それがどのようなコンポーネントによって構成されているかがおのずと定まるわけではありません。コンポーネント設計の観点をもった検討を重ねることによって、やっと妥当にコンポーネントの境界を定めて、どのようなコンポーネントの組み合わせからなるかという構成が定義できます。この過程では、明快な判断ができることもあれば、あいまいな基準で割り切れない思いをもって判断せざるを得ないこともあるかもしれません。

しかしそれを経ても、構成要素のすべてがコンポーネントである、ということにはならないでしょう。ある集合からコンポーネントとして切り出すことができるのは一部であって、それ以外は、コンポーネントとは呼べない何かとして残るはずです。もしかするとそれは、コンポーネントとコンポーネントの間の余白かもしれませんし、カードをグリッド状に並べるというレイアウトのルールかもしれません。あるいは、あるコンポーネントの外側を囲う装飾であったり、ページのセクションごとに切り替わる背景かもしれません。

CSSとデザインツールの性質の違いによって、そうしたコンポーネント観の違いが生まれてしまうこともあれば、同じデザイナーや同じ開発者の中でも、これまでの経験や考え方の違いによって、異なる見解を示すこともあるでしょう。それでも少なくとも、ある一定以上の複雑さを持つ構造を、隅から隅まですべてコンポーネントの組み合わせだけで表現するには無理があるはずです。

とはいえそれがコンポーネントではなかったとしても、設計意図が反映された何かではあります。コンポーネントとしての観点がデザインのすべてではない、ということは言うまでもありません。ビジュアルデザインという意味においては、一貫したスタイルを作り出すための統一されたルールがあるはずです。それはたとえば、タイポグラフィや余白、色やグラデーション、形状や影などといった要素によって形成されます。デザインシステムでは、そうした値のセットを定義したものをデザイントークンと呼びます。Tailwindでは、そうして定義されたデザイントークンに基づいたスタイリングができます。

これらの意味で、ユーティリティファーストは、よりデザイナーの発想に近いワークフローを実現するものです。

CSS設計の正しさとは何か ─⁠─デザインをありのままに表現する

CSS設計における最重要事項は、デザイン上の設計意図とソースコードの構造を同期させることです。

デザインは真の意味で完成することはなく、常に変化の途中であるため、CSSはそれに対応できる状態でいなければなりません。そのために重要なのは、まず、現状を正しく表現できていることです。CSSの変更が難しいといわれるのは、デザイナーが思うとおりの構造になっていないせいです。コードとして実装される過程において、デザインのもとある設計意図から離れれば離れるほど、デザイナーが思い描くのとは別物の世界を作り出していることになります。すると、デザイナーにとっての変更が、開発者にとっての変更と異なる意味を持ってしまいます。

たとえば開発者が、デザイナーの考えと異なる粒度でコンポーネントを定義したとします図1図2⁠。その時点では問題にならないかもしれません。しかしその後、デザイナーがそのコンポーネントの配置を変更すると、開発者にとっては期待を裏切られる結果になり、思いもしない対応コストが発生してしまいます。

図1 デザイナーが考えるコンポーネント
図1
図2 開発者が実装したコンポーネント
図2

極論、CSS設計の決まりに従うことよりも、それがデザインの意図に即しているかどうかのほうが重要なのです。開発者の中で良しとされる考え方が、必ずしもデザインの考え方と一致するとは限らないからです。CSSはスタイルを記述するための言語なのですから、そのための考え方がソースコードとして率直に表現されていることこそを尊重すべきです。

これは遠回りなようにも思えますが、CSS設計の最も根本的な改善策になり得ます。

CSSの特性を活かしてデザインするために

しかしこれまで述べてきたのは、Figmaのようなデザインツール一辺倒の考え方であるともいえます。ここからは視点を変えて、CSSなどのコードによってデザインすることについて考えてみます。

デザインは、それを作るために使われるツールの影響を受けながら形作られます。デザインツールにはデザインツールの性質があり、コードにはコードの性質がありますが、デザインツールによって作られたデザインは、デザインツールで作りやすいような形におのずとなります。たとえば、デザインカンプがCSSを意識した作りになっていないから実装するのに苦労する、という話はよく耳にしますが、これはある意味当然のことです。デザインツールとCSSの性質はまったく異なるからです。

CSSは、ルールを記述するものです。ある条件においてはこのようにせよ、という指示をすることで、その演繹結果がブラウザに描画されます。このときに開発者が行うのは、あくまで結果を導き出すためのルールを作り出すことであって、結果そのものを直接操作しているわけではありません。

一方、多くのデザインツールでは、目に見える形を生成したり、それに触れるようにして操作したりして、結果そのものに直接作用することでデザインします。

つまり、ツールを使用するのに伴う思考の方向が違っています。それぞれには長所と短所があるため、状況に応じて適切に使い分けることが理想的です。

しかし多くの現場においては、デザインツールを偏重したワークフローになっています。最初から最後までデザインツールによって作られたデザインカンプを、そっくりそのまま再現するようにしてCSSが作成されます。

しかし、ルールに基づいたデザインを実現するうえでは、やはりCSSに優位性があります。

レスポンシブなコンテナ ─⁠─包括的なルールの定義

Webページのコンテンツ幅を制御するコンテナのレイアウトを定義するとします図3⁠。

図3 Webページのコンテナ
図3

Figmaを使用する場合、まず決まった大きさのビューポートを想定してアートボードを作成して、アートボードの大きさに応じてコンテナのレイアウトを設定します。レスポンシブデザインを前提として、複数の種類の幅のアートボードを作成する場合、コンテナのレイアウトはアートボードごとに別々に設定する必要があります。

たとえばコンテナの幅は、スマートフォンなら359px、タブレットなら696px、デスクトップなら1280px、というように、それぞれのアートボードごとに固有の幅が設定されます。

ページごとにこれら3種類ずつのアートボードを作成するのだとすれば、デザインカンプの制作ルールとしては十分かもしれません。しかし、これらをもとにして実装されることを考えると、アートボードの幅とビューポートの幅が一致しない場合にどうすべきかが明示されていないことが問題になります。

これらの幅以外でのコンテナの仕様を示すために、アートボードの数をさらに細かく増やすような対応がなされることがありますが、根本的な解決にはなりません。デザインカンプを作成するだけではある時点での振る舞いを示したことにしかならず、問題はそれらの「間」の状態を表現できないことです。途切れ途切れになった点を打っているようなものです。それよりも必要なのは、アートボードが存在しない場合にどのように振る舞うべきかというルールです。

いくつもの点を打つことではなく、一本の線を引くことが重要なのです。

たとえばデザインカンプとは別に、表1のような仕様を定義するという方法があります。

表1 ビューポートごとに定義されたコンテナの幅の仕様
ビューポートの幅 コンテナ幅
575px 以下 100% - 2 * 24px
576px767px 540px
768px991px 720px
992px1199px 960px
1200px1399px 1140px
1400px 以上 1320px

この例では、ビューポートの幅ごとにコンテナの幅を定義することで、状況に応じた適切なレイアウトに切り替えられるようにしています。個別にアートボードを作成するのではなく、それを作成するためのルールを抽出して明文化しています。

CSSでは、こうしたルールにはすんなりと対応できます。たとえば次のように実装できます。

.container {
  width: calc(100% - 2 * 24px);
  margin-inline: auto;
}

@media (min-width: 576px) {
  .container {
    width: 540px;
  }
}

@media (min-width: 768px) {
  .container {
    width: 720px;
  }
}

@media (min-width: 992px) {
  .container {
    width: 960px;
  }
}
(後略)

仕様としてはこれで十分でしょう。しかし、CSS独自の性質に基づいて考えれば、さらにシンプルで包括的なルールを設計できます。コンテナの幅を段階的に切り替えるのではなく、シームレスに変化させると考えると、次のようなルールになります。

.container {
  max-width: 1320px;
  margin-inline: auto;
  padding-inline: max(5vw, 24px);
}

前の例では、ブレイクポイントごとに固有の幅を設定していたのに対して、この例ではそうした指示はせずに、ビューポートのサイズに応じておのずと適切な幅が導き出されるルールを定義しています。

本質的に考えると、前の例のように、コンテナの幅が決まった大きさに固定される必要はないはずです。一方、コンテナの外側にある程度余白が確保されていることは重要でしょう。この余白はビューポートの大きさに基づいて決まると考えて、ビューポートの幅に基づいた値を設定しています。そのうえで、余白が狭くなりすぎないようにするために、24pxが最小値になるようにしています。

このようなルールが定義できると、条件ごとの個別ルールをいくつも設けずに済みます。条件ごとに開発者が細かく手を入れていく必要がなくなり、実際のユーザーの利用コンテキストにおのずと適応させることができます。これとは逆に、条件ごとに別々のルールを適用することを繰り返していると、開発者が考慮すべき要素の数が膨大になり、いずれ管理不可能な状態に陥ってしまいます。

それを防ぐために必要なのは、個々の結果だけではなく、それを導き出すためのルールを意識してデザインすることです。このようなルールに基づいた手法は、ほかにもいくつか挙げることができます。

利用可能なスペースに応じて変化するレイアウト ─⁠─コンテキストに自律的に適応する

CSS レイアウトについてのコンテンツEvery Layout⁠日本語訳は書籍『Every Layout─⁠─モジュラーなレスポンシブデザインを実現するCSS設計論』[1]では、レイアウトプリミティブという考え方が紹介されています。レイアウトプリミティブとは、レイアウトのための単一の責務を持つコンポーネントのことで、前述したコンテナのような、レイアウトのパターンでもあります。これらの特徴の一つは、メディアクエリのブレイクポイントを使わずに、レスポンシブなレイアウトが実現できるようになっていることです。

例として、レイアウトプリミティブの一つである「Grid」について紹介します。カードなどのUI要素がグリッド状に並ぶレイアウトはよくあります図4⁠。

図4 グリッド状に並ぶレイアウト
図4

そうしたレイアウトは、1行当たりのカラム数がブレイクポイントによって切り替わるように実装されるのが一般的でしょう。ビューポートの大きさに応じて、カラム数が適切に保たれるようにするためです。

しかし適切なカラム数は、ビューポートの大きさに基づいて導き出されるわけではありません。本来は、そのレイアウト自身が占有できる面積に基づいて決まるはずです。というのも、ページの幅いっぱいまで広がっているグリッドと、サイドバーなどの隣に配置されて幅が制限されているグリッドでは、ビューポートの大きさは同じでも、適切なカラム数は異なります。つまり、ブレイクポイントに依存した実装では、このような本来のファクターに基づいた対応ができません。

そこで、レイアウトプリミティブのGridでは、実際に利用可能なスペースに基づいて適切なカラム数が導き出されるしくみになっています。具体的には、次のような実装です。

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
  grid-gap: 1rem;
}

これを用いると、カラムの幅は最低250px以上になるように維持されたうえで、カラム数はできるだけ多くなります。たとえば、800pxのコンテナの中に配置されると、自動的に3カラムのグリッドになります。

TailwindにGridを取り入れるなら、次のようなプラグインとして実装するのがよいでしょう。

tailwind.config.js
const plugin = require('tailwindcss/plugin')

const autoGrid = plugin(
  function ({ matchComponents, addComponents, theme}) {
    const values = theme('autoGrid')
    
    matchComponents(
      {
        'auto-grid': (value) => ({
          display: 'grid',
          gridTemplateColumns: `repeat(auto-fill, min max(min(${value}, 100%), 1fr))`,
        }),
      },
      { values }
    )
    
    addComponents({
      '.auto-grid-none': {
        display: 'revert',
        gridTemplateColumns: 'revert',
      },
    })
  },
  {
    theme: {
      autoGrid: ({ theme }) => ({
      ...theme('spacing'),
      }),
    },
  }
)

module.exports = {
  theme: {
    // ...
  },
  plugins: [
    autoGrid,
    // ...
  ],
}

これは次のように使用します。

<div class="auto-grid-64 gap-4">
  <div>01</div>
  <div>02</div>
  <div>03</div>
  <!-- ... -->
</div>

注意すべきポイントはいくつかあります。まず、クラス名としてgridの代わりにauto-gridを使用していますが、これはTailwindにある既存のクラスとのバッティングを避けるためです。

次に、プラグインのAPIのmatchComponents()関数を使用することで、最小のカラム幅として任意の値を指定できるようにしています。デフォルトではテーマのspacingと同様の値が使用できるほか、auto-grid-[32rem]のような指定をすることもできます。

Tailwindでは、CSSのレイヤとしてbasecomponentsutilitiesが定義されています(詳しくは第2章を参照のこと⁠⁠。addComponents()関数やmatchComponents()関数を使って実装されたスタイルは、componentsに属します。

Tailwindのほとんどのクラスはutilitiesですが、唯一containerクラスだけがcomponentsに属します。componentsutilitiesによって上書きできるように意図されているため、このGridのようなスタイルを含めるのに適しています。

動的に変化する色の定義 ─⁠─実体ではなく定義に基づいた設計

Tailwindでは、ダークモード向けのスタイルを実装するためのdark修飾子が提供されています。これを使うと、ダークモードが有効になっている場合にのみスタイルを適用できます。

<div class="bg-white text-slate-900 dark:bg-slate-800 dark:text-white">
  こんにちは!
</div>

しかし、このような方法でダークモードに対応することには問題があります。なぜならこれでは、調整が必要な箇所の数だけ、個別にスタイルを定義しなければならなくなるからです。

基本的に、ダークモード対応として必要なのは、色を変更することです。それを個別に行うとすれば、色を変更するすべての箇所のスタイルを一つ一つ上書きして回ることになります。それには非常に手間がかかるうえに、作業ミスが増えたり、あとからの変更に対応しづらくなったりしてしまいます。

こうした個別的な対応を繰り返すのではなく、システムとして解決するために、コンテキストに応じて動的に色が変化するカラーパレットを定義するという手法があります。たとえばredという色は、ライトモードでは#ff3b30に、ダークモードでは#ff453aになり、blueという色は、ライトモードでは#007affに、ダークモードでは#0a84ffになる、というようなしくみに基づいて設計するのです。

これはCSSでは、次のようにすると実現できます。

:root {
  --red: #ff3b30;
  --blue: #007aff;
}

@media (prefers-color-scheme: dark) {
  :root {
    --red: #ff453a;
    --blue: #0a84ff;
  }
}

.text-red {
  color: var(--red);
}

.text-blue {
  color: var(--blue);
}

これによって、ライトモードとダークモードの色をあらゆる箇所に別々に指定する必要はなくなります。代わりに、決まった色名だけを指定することで、おのずと両方に対応できるようになります。

Tailwindにこのしくみを取り入れるなら、次のようなプラグインとして実装するのがよいでしょう。

tailwind.config.js
const lodash = require('lodash')
const colors = require('tailwindcss/colors')
const { parseColor } = require('tailwindcss/lib/util/color')
const plugin = require('tailwindcss/plugin')

const dynamicColors = (() => {
  function generateDeclarations(settings) {
    (中略)
  }
  
  function generateTheme(settings) {
    (中略)
  }
  
  return plugin.withOptions(
    function (options) {
      const styles = {
        ':root': {
          ...generateDeclarations(options.light),
          '@media (prefers-color-scheme: dark)': {
            ...generateDeclarations(options.dark),
          },
        },
      }
      
      return function ({ addBase }) {
        addBase(styles)
      }
    },
    function (options) {
      const settings = lodash.merge(options.light, options.dark)
      return {
        theme: {
          extend: generateTheme(settings),
        },
      }
    }
  )
})()

module.exports = {
  theme: {
    // ...
  },
  plugins: [
    dynamicColors({
      light: {
        backgroundColor: {
          DEFAULT: colors.white,
          variant: colors.slate['100'],
        },
        textColor: {
          DEFAULT: colors.gray['800'],
          muted: colors.gray['500'],
        },
      },
      dark: {
        backgroundColor: {
          DEFAULT: colors.zinc['900'],
          variant: colors.zinc['800'],
        },
        textColor: {
          DEFAULT: colors.zinc['50'],
          muted: colors.zinc['400'],
        },
      },
    }),
    // ...
  ],
}

これは次のように使用します。

<body class="bg-dynamic text-dynamic">
  <!-- ... -->
  
  <div class="bg-dynamic-variant">
    <!-- ... -->
  </div>
  
  <div class="text-dynamic-muted">
    <!-- ... -->
  </div>
</body>

特定の色を直接使用するのではなく、使用する色のリストをあらかじめ定義したうえで、その中から選択して使用することがこの手法の要です。最初の定義には苦労するかもしれませんが、それさえうまくできれば、以降は作業の負担を大きく軽減できます。

コードが先か⁠デザインが先か

多くのデザインツールにはこのようなルールを表現する術がなく、普通にデザインカンプを作成しているだけではこのような発想に至るのは困難です。このようなルールの一部については、デザインツールでも似たことができるかもしれませんが、CSSというルールありきの言語だからこそできる表現には遠く及びません。

もちろん、デザインツールにはデザインツールに適した用途があります。UIの構成やビジュアルデザインを検討する際には、コードよりもデザインツールのほうがずっと速く試行錯誤できるはずです。これはコードの制約がないからこそです。だからこそ、実装しづらいデザインが生まれてしまうということもありますが、これらは表裏一体です。

そこでやるべきは、あらかじめすべてをデザインカンプ上だけで決定しようとするのではなく、デザインの最中でデザインツールとコードを行き来して、複数の視点を交互に切り替えながら作業を進めていくことです。⁠デザインが先で実装が後」という分離された段階的なプロセスではなく、ゴールに向かうための反復運動のようにとらえるのです。

デザインと開発が分業されている場合、現実的に考えると、それを実現するのは容易ではないかもしれません。しかし、これらが切り離されている限りは、CSS設計の問題を解決するのは非常に困難です。ですから、これこそがCSSの開発者としてアプローチすべき根本的な問題なのです。

特集のおわりに

時に人がいうのは、Tailwindはデザインからシステム的な思考を排除して、場当たり的な考え方を助長するものだ、ということです。ユーティリティクラスだけを使用することで、体系化されていない、局所的なルールばかりでデザインするためのフレームワークであると。

たしかに、Tailwindにはそういった側面もあります。しかしそれは、間違った前提に基づいた意見です。というのも、Tailwindではなく従来のやり方を採用したからといって、デザインの複雑さが抑制されるわけではないからです。

これまで述べてきたように、局所的なルールであっても、いったんは受け入れられる環境が必要です。真の意味ですべてのデザインが「完成」しているわけではない以上、段階的な変化が望めるような構造が重要なのです。そして、その環境をどのように使いこなして整理を行っていくかは、使い手に委ねられています。

フレームワークの設計においては、何ができるかと同じくらい、何ができないかが重要だといわれます。したがって、これまで述べてきたような考え方は否定的にとらえられてもやむを得ない部分があります。そもそも最初から無秩序なものなど作れないようになっているべきだと。しかしそれは「最初から正解を出せ」といっているようなものです。実のところデザインには絶対的な正解はなく、相対的に優れた答えがあるだけです。試行錯誤の中で少しずつそれに近付いていくことしかできません。だからこそ、Tailwindなのだと思います。

これまでの考え方を見なおして、方法論を転換させるべきときが来ているのかもしれません。

おすすめ記事

記事・ニュース一覧