Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Tailwind v4互換のセマンティックなCSSを正しく書く(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Tailwind v4互換のセマンティックなCSSを正しく書く(翻訳)

HTMLでUIを構築するにあたり、Tailwindとうまく調和する再利用可能なCSSクラスをどうやって書くかをいろいろと考えさせられました。検討中に、他のCSSライブラリはこの問題にどう取り組んでいるかをチェックしてみたのですが、結論から先に言えば、ほとんどのライブラリのアプローチは不適切だったのです。


最初に、私が他のライブラリで見かけた2通りの方法を紹介し、それから私の方法をお目にかけたいと思います。

hunvreus/basecoat - GitHub

以下は、Basecoatというライブラリで行われているバッジの定義です。

@layer components {
  .badge,
  .badge-primary,
  .badge-secondary,
  .badge-destructive,
  .badge-outline {
    @apply inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden;
  }
}

定義そのものは1行ですが、バッジのあらゆるバリアントが{}の中にギュウギュウに押し込まれています。

saadeghi/daisyui - GitHub

DaisyUIというライブラリのバッジ記法は、似ている部分もありますが、大きく異なっている部分もあります。

.badge {
  @layer daisyui.l1.l2.l3 {
    @apply rounded-selector inline-flex items-center justify-center gap-2 align-middle;
    color: var(--badge-fg);
    border: var(--border) solid var(--badge-color, var(--color-base-200));
    font-size: 0.875rem;
    width: fit-content;
    background-size: auto, calc(var(--noise) * 100%);
    background-image: none, var(--fx-noise);
    background-color: var(--badge-bg);
    --badge-bg: var(--badge-color, var(--color-base-100));
    --badge-fg: var(--color-base-content);
    --size: calc(var(--size-selector, 0.25rem) * 6);
    height: var(--size);
    padding-inline: calc(var(--size) / 2 - var(--border));
  }
}

Tailwind固有の@applyがナマのCSSと入り混じっていて、カスタムプロパティが多用されています。レイヤの下でネストしている名前も紛らわしくなっています。

🔗 どこが問題か

basecoatとDaisyUIのどちらにも以下の3つの問題が共通して存在しています。

問題点 これが問題になる理由
ツリーシェイクが効かない @layerに記述したものは、使われていなくても配信される(20個のクラスを定義すれば20個すべてが配信される)。
オートコンプリートが効かない TailwindのIntelliSenseは、@layer内のクラス名自体は補完対象にしない。
ステートが読みにくい focus-visible:dark:aria-invalid:をスタイルごとにプレフィックスすると開発者にとって大量のノイズになる。

🔗 私の方法

以下は、私が書いたHTML UIのバッジクラスです。

@utility ui-badge {
  :where(&) {
    @apply inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 transition-[color,box-shadow] overflow-hidden border-transparent bg-primary text-primary-foreground;

    & > svg {
      @apply size-3 pointer-events-none;
    }

    @variant hover {
      @apply bg-primary/90;
    }

    @variant focus-visible {
      @apply border-ring ring-ring/50 ring-[3px];
    }

    @variant aria-invalid {
      @apply ring-destructive/20 border-destructive;
    }
  }

  @variant dark {
    :where(&) {
      @variant aria-invalid {
        @apply ring-destructive/40;
      }
    }
  }
}

基本的な表示結果は同じですが、CSSの構造はまったく異なります。この方法が優れている理由を詳しく説明しましょう。

🔗 1: @utilityに書くことでツリーシェイクとオートコンプリートを有効にしている

@utility1で定義したCSSクラスは、マークアップで実際に使わない限り配信されません。CSSクラスが20個定義されていても、使っているCSSクラスが3つなら、配信されるのはその3つだけです。これはTailwindがユーティリティ生成において重視している設計思想ですが、標準の@layerはこの管理対象外になります。

@utilityを使えば、VS CodeのIntelliSense拡張にも登録されます。エディタにプレフィックスを入力すれば、すべてのアフォーダンスが表示されるようになります。見つけやすいことは大事です。エディタでクラスが表示されないと、クラスの存在を見落としてインラインで同じものを書いてしまうことになります。

🔗 2: ui-のようなプレフィックスでアフォーダンスを明示している

コードベースにクラス名が.btnと書かれているだけでは、それをどう扱ったらよいかという情報が何も伝わりません。
「Bootstrapのクラスなのか?」「CSSの詳細度が予測できないような古いセマンティックCSSクラスなのか?」「それともユーティリティクラスか?」

適切なプレフィックスをCSSクラス名に追加することで、そのCSSクラスがアフォーダンスであることがわかるようになります。
たとえばui-buttonというクラス名を見れば、「これがアフォーダンスである」こと、すなわち「ユーティリティクラスと組み合わせて使うよう設計された表示パターンである」「詳細度はゼロである」ことがわかります。つまり前世紀の古臭いセマンティックCSSではないということです。

プレフィックスはエディタのオートコンプリートでも有用です。ui-と入力すれば、システム内のアフォーダンスクラスを一望に見渡せます。プレフィックスはチームの慣例に合わせてaf-でもlook-でも何でも構いませんが、何らかの慣例を定めておけば、それらのクラスが異なるルールで振る舞うことが開発者にひと目で伝わるようになります。

🔗 3: :where()でアフォーダンスの詳細度をゼロに保っている

@utilityで記述されるユーティリティCSSクラスは、Tailwindのユーティリティレイヤ(カスケード順の最後)に配置されます。
通常はこれが望ましい振る舞いですが、アフォーダンス用のCSSクラスはユーティリティCSSクラスで「上書き可能」にしておくべきです。

CSSの:where()擬似クラス関数を使うことで、詳細度(specificity)をゼロにできます2
たとえば:where(.ui-badge)の詳細度は0,0,0となり、bg-red-500などの通常のユーティリティCSSクラスは0,1,0となるので、ユーティリティクラスが常に勝つようになります。

<span class="ui-badge bg-red-500">Error</span>

!importantを使う必要もなければ、カスケードが衝突することもありません。アフォーダンス用CSSクラスがデフォルトのスタイルを提供し、それをユーティリティCSSクラスでいつでもカスタマイズできるようになります。

🔗 4: @variantでステートのスタイルを読みやすくしている

以下のBasecoatの方法と見比べてみてください。

@apply focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40;

上はTailwind v4の@variantを使って以下のように書き換えられます。

@variant focus-visible {
  @apply border-ring ring-ring/50 ring-[3px];
}

@variant aria-invalid {
  @apply ring-destructive/20 border-destructive;
}

@variant dark {
  :where(&) {
    @variant aria-invalid {
      @apply ring-destructive/40;
    }
  }
}

focus-visiblearia-invaliddarkというステートごとにブロックが割り当てられています。これなら、「フォーカスが当たったときに何が変更されるか」「無効な場合に何が変更されるか」「ダークモードで何が変更されるか」がひと目でわかります。この構造は、開発者が思い描くステートの構造にそのまま一致しています。

@variant記法には実用的なメリットもあります。
@apply hover:bg-red-500という指定は、コロン:がCSS構文として解析されてからTailwindで処理されるため、SvelteやVueの<style>ブロックで壊れる可能性があります(#17993)。@variant記法で書けば、この問題を確実に回避できます。

🔗 5: @applyで互換性の問題を解決している

人によっては、私がナマのCSSではなくTailwindの@applyを使っているのを見て不思議に思うかもしれません。その答えは互換性を維持するためです。

たとえば@apply bg-primary text-sm px-2と書くと、「色」「スペーシング」「タイポグラフィー」についてユーザー定義のTailwindテーマを参照することになります(ユーザー定義のテーマが存在する場合)。
後でユーザー定義テーマのprimaryの色をインディゴからオレンジに変更すれば、そのアフォーダンスCSSクラスでも自動的にオレンジが使われるようになります。
同様に、ユーザー定義テーマでスケールの値をカスタマイズすれば、px-2はカスタム定義の通りの値に解決されます。

しかしDaisyUIがやっているように--badge-fg--color-base-200などの中間カスタムプロパティをどんどん定義する方法は、言ってみればライブラリがデザインシステムをもう1つ作っているようなものです。開発者は、DaisyUI独自のデザインシステムと、自分たちのアプリケーションで使うデザインシステムをいちいち対応付けなければならなくなり、これが軋轢を生み出します。

@applyを私のように使うことで、この軋轢を解消できます。アフォーダンスは、ユーザーが使うのと同じユーティリティCSSクラスで表現されています。アフォーダンスでは開発者が使うユーティリティクラスを「そのまま」組み合わせているからです。


Tailwind v4の@utility@apply@variantディレクティブは、単なる目新しい構文ではなく、:where()と適切に組み合わせることで、エディタで適切に補完され、ツリーシェイクも有効になり、読みやすさも改善され、ユーティリティと組み合わせやすくなるのです。

これがv4ならではのアプローチです。皆さんもこれで作ってみましょう。

関連記事

CSS: フロントエンドアーキテクチャに「アフォーダンス」層も必要な理由(翻訳)

CSS: 2025年に押さえておきたい最新CSS機能13選(翻訳)

CSS: z-indexの問題はisolationプロパティで解決できる(翻訳)


  1. 訳注: @utilityはTailwind v4で導入されました。参考: Adding custom utilities 
  2. 訳注: なお:where(&)はTailwindのネスト構文で使われる書き方です。&はクラス自身を表します。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。