Tailwind CSSにおけるHTMLのコンポーネント化とCSSのコンポーネント化

Tailwind CSSにおいて、スタイルの組み合わせを抽象化したいと考えたとき、対処としてはいくつかの方法が考えられる。

もっとも一般的なのは、コンポーネントやパーシャルとして、クラス属性値とHTMLをセットで抽出する方法。

<template>
  <div>
    <img class="rounded" :src="img" :alt="imgAlt" />
    <div class="mt-2">
      <div>
        <div class="text-xs font-bold uppercase tracking-wider text-slate-600">{{ eyebrow }}</div>
        <div class="leading-snug font-bold text-slate-700">
          <a :href="url" class="hover:underline">{{ title }}</a>
        </div>
        <div class="mt-2 text-sm text-slate-600">{{ pricing }}</div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: ['img', 'imgAlt', 'eyebrow', 'title', 'pricing', 'url'],
};
</script>

Reusing Styles - Tailwind CSS

これによってスタイルに関するコードが一元化されるため、新たにCSSを記述せずとも共通化の役割を果たせる。


次に、クラスの文字列だけを変数化することで再利用可能にする方法。shadcn/uiでは、コンポーネントに適用するクラスの文字列をexportして提供することで、ユーザーがスタイルだけを独立して利用できるようにしている。コンポーネント固有のマークアップやロジックに縛られないことが特徴である。

shadcn-ui/ui/templates/next-template/components/ui/button.tsx:

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "underline-offset-4 hover:underline text-primary",
      },
      size: {
        default: "h-10 py-2 px-4",
        sm: "h-9 px-3 rounded-md",
        lg: "h-11 px-8 rounded-md",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

// (中略)

export { Button, buttonVariants }

この方法では、クラスを構成するためのライブラリとしてclsxClass Variance AuthorityTailwind Variantsが用いられる。また、それらに応じた専用の正規表現をエディタに設定することで、Tailwindのインテリセンスを機能させることができる。


もう一つが、自分でCSSを書くという方法。

<!-- Before extracting a custom class -->
<button
  class="rounded-full bg-violet-500 px-5 py-2 font-semibold text-white shadow-md hover:bg-violet-700 focus:outline-none focus:ring focus:ring-violet-400 focus:ring-opacity-75"
>
  Save changes
</button>

<!-- After extracting a custom class -->

<button class="btn-primary">Save changes</button>
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .btn-primary {
    @apply rounded-full bg-violet-500 px-5 py-2 font-semibold text-white shadow-md hover:bg-violet-700 focus:outline-none focus:ring focus:ring-violet-400 focus:ring-opacity-75;
  }
}

Reusing Styles - Tailwind CSS

ある意味、これがもっともTailwindらしくないやり方ではあるが、有効になる場面もある。

また似たアプローチとしては、普通にCSSを記述するのではなく、CSSを生成するためのプラグインを作成するという方法もある。これについては以前の僕のブログ「Tailwind CSSで引数のあるMixinのような仕組みを作る方法(改)」で紹介した。

HTMLのコンポーネント化とCSSのコンポーネント化

それでは、これらのうちどれを採用すべきか。判断するには、そのスタイルはマークアップから独立して存在し得るか否かという観点が重要になる。Tailwindにおいては、HTMLにおけるコンポーネントとCSSにおけるコンポーネントという概念は区別して考えられるからである。

従来のBEM的なアプローチを思い返せば、CSSのためのクラスとHTMLは一対一の関係になっているのが基本だった。

<!-- Even with custom CSS, you still need to duplicate this HTML structure -->
<div class="chat-notification">
  <div class="chat-notification-logo-wrapper">
    <img class="chat-notification-logo" src="/img/logo.svg" alt="ChitChat Logo">
  </div>
  <div class="chat-notification-content">
    <h4 class="chat-notification-title">ChitChat</h4>
    <p class="chat-notification-message">You have a new message!</p>
  </div>
</div>
Reusing Styles - Tailwind CSS

CSSはこうした決まったHTMLのために記述されるものであって、そこからは独立して存在できないように意図されている。その場合は1つ目の方法を採用するのが妥当だ。これはHTMLまでまとめてパターン化するものなので、便宜上「HTMLのコンポーネント化」と呼ぶこととする。一般に、ほとんどの場面ではこの選択になるだろう。

一方「CSSのコンポーネント化」と言えるのが、スタイルの組み合わせを一つのクラスとして束ねてしまう3つ目の方法である。これはTailwindにおいては基本的に推奨されないが、しかしTailwind自身が提供しているクラスの中にはこれに該当するものがある。containerクラスである。

Tailwindには、ITCSSから着想を得て取り入れられたレイヤーの概念がある。現代的な仕様で言うところのCascade Layersに近いものである。

@tailwind base;
@tailwind components;
@tailwind utilities;

Tailwindから提供されるすべてのクラスは、このbase / components / utilitiesのいずれかのレイヤーに分類されている。ほとんどのクラスはutilitiesレイヤーだが、唯一containerクラスだけがcomponentsレイヤーに属している(公式プラグインまで含めれば、proseクラスform-*クラスaspect-*クラスなどもcomponentsである)。

このcomponentsレイヤーは、スタイルの組み合わせをクラスとしてパターン化したものであり、後ろのutilitiesレイヤーのクラスから上書きされることが想定されている。このレイヤリングの考え方はITCSSから影響を受けたものであるが、構成をそのまま継承しているわけではなく、微妙なアレンジが施されている。ITCSSの各レイヤーをTailwindのそれと照合してみると、次のようになる。

ITCSSTailwind CSS
genericbase
elementsbase
objectscomponents
componentsHTMLテンプレート
utilitiesutilities

ここで重要なのが、componentsレイヤーとして指し示される対象が変化していることである。

ITCSSにおけるcomponentsレイヤーとは、特定の役割のために構築された、直接それとわかるようなはっきりとした形を持ったパーツ——普通に言われるところの「コンポーネント」——のことを指している。しかしTailwindにおいては、それはCSSのクラスではなく、HTMLのテンプレートとして表現されることがセオリーだ。つまり、「HTMLをコンポーネント化」したものがそれと対応する。

問題は、Tailwindにおけるcomponentsレイヤーと対応する、ITCSSのobjectsレイヤーとは何かということ。これは、OOCSSの原則である「構造と表層の分離(Separate structure and skin)」から影響を受けたものであり、ITCSSにおいてはそれは、装飾のない抽象的なパターンだとされている。具体例としては、OOCSSで言うところのmediaオブジェクトや、グリッドシステムのカラム、そしてTailwindのcontainerクラスのようなもの。実装としては、inuitcssのobjectsディレクトリや、Every Layoutのレイアウトプリミティブなども参考にできるだろう。

この両者の重要な差異は、前者はHTMLの構造を規定するが、後者は必ずしもそうではないという点である。たとえば、ボタンというUI要素は前者に含まれる。それはbutton要素やa要素として描画されることが意図されており、何にでもあてがえるわけではない。しかし後者は、自身がどのようなHTMLに対して適用されるかという関心を持たない。理論上、どのような場面で適用されても問題がないように構築されるものだ(もっとも、「objectsレイヤーに含まれるコンポーネント」が入れ子構造に依存する可能性はあるが、それはレイアウト上の理由でしかない)。

後者のように、特定の用途やHTMLの構造に縛られないような、汎用的かつ意味のある繰り返しのパターンがあるのであれば、CSSとしてコンポーネント化する方が扱いやすくなることもある。僕がよく作るのは、コンテンツの最大幅を制御するwrapperクラスや、グリッドレイアウトのためのauto-gridクラスheading-2クラスのような標準的なテキストスタイル(例: Material DesignHuman Interface Guidelines)のためのものなど。いずれも、utilitiesのように単機能ではなく、複合的な一つのセットになることで意味のあるものだ。

componentsレイヤーがutilitiesレイヤーと異なるのは、後ろのレイヤー、つまりutilitiesレイヤーからの上書きができるという点である。これによって、あくまでcomponentsレイヤーのスタイルは「デフォルト」として扱いつつ、utilitiesレイヤーを用いることで用途に応じた調整ができるようになっている。逆に、utilitiesであるsr-onlyクラスは、一見複数の機能の組み合わせのようにも見えるが、調整は想定されておらず常にこのまま適用されるべきであるためこのレイヤーに位置している。

ただし実は、表に示したように、ITCSSとTailwind CSSのレイヤーが明確に一対一の関係にマッピングできるわけではない。ITCSSのcomponentsレイヤーとTailwind CSSのcomponentsレイヤーは、一部役割が被るところがある。たとえば、公式プラグインにあるproseクラスやform-*クラスはITCSSで言えばcomponentsに近いが、HTMLテンプレートではなくTailwindのcomponentsレイヤーで実装されている。前述の表をより正確に表すとすれば次のようになるだろう。

ITCSS
Tailwind CSS
generic
base
elements
base
objects
components
components
HTMLテンプレート
utilities
utilities

最後に、前半で紹介した2つ目の方法は、HTMLのコンポーネント化とCSSのコンポーネント化のどちらでもないような、間に位置するアプローチである。しかし実際にそれを用いるのは、大抵の場合、特定の用途のためのスタイルを抽出するとき、つまりITCSSで言うcomponentsのようなものを作る場面だろう。実装の柔軟性のために、それをHTMLから独立した状態で扱えるようにしつつも、やはり実際のところどのような要素に適用されるかが決まっている。そのような場合に採用するのが適切である。