🍁

Vue × Tailwind CSS × CVA × tailwind-mergeによるコンポーネント実装

2023/11/05に公開

はじめに

今回はVueとTailwind CSS、CVA(Class Variance Authority)、そしてtailwind-mergeを組み合わせたVueコンポーネントの例を紹介します。

CVAとは

https://cva.style/docs

公式ドキュメントの文章を要約すると、

CVA(Class Variance Authority)は、従来のCSSアプローチにおける手動でのクラスとpropのマッチングや型の手動追加などの手間を取り除き、開発者がUI開発をより集中できるようにするツールです。

もっと言い換えると、UIコンポーネントにおけるデザインパターン(variants)ごとのCSSクラスの条件分岐を簡潔に実装できるようになるツールです。

以下のようなシンプルなテキストフィールドコンポーネントを例にしてみましょう。

<input
    :class="[
      { 'bg-red-500': intent === 'primary' },
      { 'bg-green-500': intent === 'secondary' },
      { 'bg-blue-500': intent === 'tertiary' },
      'text-base',
    ]"
    type="text"
  />

デフォルトでtext-baseが当たっていて、variantpropsでprimaryを指定するとbg-red-500が、secondaryを指定するとbg-green-500の背景色が、tertiaryを指定すると...というような条件によって異なるtailwind cssのクラスが当たるデザインパターンがあるとしましょう。

これはとてもとても単純な例なので、背景色ぐらいしかクラスを当てていませんが、実際には以下のようにもっと多くのtailwindクラスが当たると思います。(設定しているクラスは適当です笑)

<template>
  <input
    :class="[
      { 'bg-red-500 text-yellow min-h-[36px]': intent === 'primary' },
      { 'text-sm bg-green-500 w-full border border-green-700 disabled:text-gray-300 min-h-[40px] rounded-md': intent === 'secondary' },
      { 'text-lg bg-blue-500 font-semibold max-w-[200px] min-h-[44px] rounded-lg border border-black outline-0': intent === 'tertiary' },
      'text-base',
    ]"
    type="text"
  />
</template>

これはtailwindあるあるの実装なのですが、こうなると、以下の問題が発生します。

  • テンプレート部分にクラスがいっぱいあってコンポーネントのマークアップ構造が把握しづらい。(特にCardなどのコンポーネントとかになってくると構造が一目ではわかりづらくなる)
  • 条件分岐が複雑になってくるとどういう条件下でどのようなクラスが当たっているのかわかりづらい。
  • 条件分岐で実装の抜け漏れがあるかもしれないという焦燥感

こうしたデザインパターン(variants)と条件分岐をシンプルな書き方で実装できるようになりますよ〜というツールがCVAなのです。

https://cva.style/docs/getting-started/variants

Vue × Tailwind CSS

ReactやVueなどのフレームワークを使ってコンポーネントベースで開発するアプリケーションの場合、CSSのアプローチとしてTailwind CSSを採用することがあります。

今回はそのようなケースにおける実装を、CVAと組み合わせたらどうなるのかというのをご紹介します。

https://tailwindcss.com/

tailwind-merge

https://zenn.dev/dino3616/articles/325c23ab6d2d6d

上記の記事にもありますが、Tailwind CSSのJITモードでは、tailwindクラスの上書きがうまくいかないときがあります。

例えば下のコードを見てみましょう。

<p class="bg-red-500 bg-green-500">テキストテキスト</p>

コンポーネントを出力した時に上記のようなコードになっていた際、実装者としてはbg-green-500が適用されていてほしいのに、bg-red-500が優先されてしまう、なんてことがあります。

そこで力技で

<p class="bg-red-500 !bg-green-500">テキストテキスト</p>

というように!importantで上書きとかをしてしまうと、スタイルの上書き合戦になってしまいかねません。。

こうした同一の機能を持ったユーティリティクラスの競合を解決するツールとして、tailwind-mergeがあります。

https://github.com/dcastil/tailwind-merge

tailwind-mergeのtwMerge()を使うことで、例えばコンポーネント側で以下のようになったとき、

<p :class="twMerge(['bg-red-500', 'bg-green-500'])">テキストテキスト</p>

出力としては

<p class="bg-green-500">テキストテキスト</p>

というように、後から記載したクラスが優先されるように出力されます。

サンプルのコードはごくシンプルな例ですが、実際にコンポーネントを組んでいるといろんなユーティリティクラスで競合が発生することがあります。

tailwind cssを使っていなくてもクラスの詳細度でスタイルを上書きすることがありますよね。それと一緒です。

ということで、詳細度が関係ないユーティリティ系ライブラリでは、上書きはできるだけ避けたいシチュエーションなのです。加えて上書き合戦をしている要素のスタイルは見通しが悪く、さらにtailwindを使っている場合は不要なクラスがレンダリングされている状態になってしまいます。

これをtailwind-mergeを使って緩和します。

コンポーネントを実装してみよう

ケーススタディ

それではVue × Tailwind CSS × CVA × tailwind-mergeを使って、下記の架空のデザインシステムライクなテキストフィールドコンポーネントを実装してみましょう。

このデザインシステムライクなテキストフィールドには以下のデザインパターンがあります。

  • variantsが"primary"、"secondary"、"tertiary"の3種類
  • Sizeが"Large"、"Medium"、"Small"の3種類
  • バリデーションエラー時のスタイル
  • 非活性時のスタイル

variants × Size だけで計9通りのデザインパターンがあり、また各variantでエラー、非活性があるので + 3 × 2の6通り、合計で15通りのパターンがあります。

それぞれの機能を分離して設計していかないと条件分岐がめんどくさくなりそうですね〜。

Vue環境, tailwind CSSの導入

割愛します。

CVAの導入

https://cva.style/docs/getting-started/installation

pnpmを使用している場合、以下コマンドでインストールできます。

pnpm i class-variance-authority

使いたいコンポーネントのvueファイルにインポートします。

 <script setup lang="ts">
+ import { cva, type VariantProps } from "class-variance-authority";
 </script>

tailwind-mergeの導入

https://www.npmjs.com/package/tailwind-merge?activeTab=readme

pnpmの場合は以下コマンドを使います。

pnpm i tailwind-merge

使いたいコンポーネントのvueファイルにインポートします。

 <script setup lang="ts">
+ import { twMerge } from "tailwind-merge";
 import { cva, type VariantProps } from "class-variance-authority";
 </script>

tailwind.config

とりあえず今回のデザインで使っている色だけ登録します。色の一部はSpindleから持ってきました。
https://spindle.ameba.design/styles/color/

tailwind.config.js
export default {
  content: ["./index.html", "./src/**/*.{vue,js,ts}"],
  theme: {
    extend: {
      colors: {
        "color-surface-primary": "#fff",
        "color-surface-tertiary": "#eee",
        "color-surface-disabled": "#cfcfcf",
        "color-surface-caution-light": "rgba(217, 28, 11, 0.05)",
        "color-border-medium-emphasis": "rgba(8, 18, 26, 0.3)",
        "color-border-high-emphasis": "rgba(8, 18, 26, 0.61)",
        "color-border-caution": "#d91c0b",
        "color-text-high-emphasis": "#08121a",
        "color-text-disabled": "#777",
        "color-focus-ambiguous": "rgba(0, 145, 255, 0.3)",
      },
    },
  },
  plugins: [],
};

そんなこんなで

Vue, tailwind CSS, cva, tailwind-merge環境が整ったので、ケーススタディにあるようなシンプルなテキストフィールドコンポーネントを実装していきます。

templateのhtml部分

<input
    :class="twMerge(textField({ intent, size, margin, fullWidth, hasError }))"
    :type="type"
    :value="modelValue"
    @input="onInput"
  />

script setup内

import { twMerge } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";

const textField = cva(
  "outline-0 pl-4 pr-11 text-base bg-color-surface-primary border-color-border-medium-emphasis focus:shadow-sm disabled:bg-color-surface-disabled disabled:text-color-text-disabled",
  {
    variants: {
      intent: {
        primary: "rounded-lg border",
        secondary: "border-b",
        tertiary: "bg-color-surface-tertiary border-b rounded-sm",
      },
      size: {
        lg: "min-h-[48px]",
        md: "min-h-[40px]",
        sm: "min-h-[36px]",
      },
      margin: {
        none: "",
        sm: "mt-2",
        md: "mt-4",
        lg: "mt-6",
        xl: "mt-8",
      },
      fullWidth: {
        true: "w-full",
        false: "",
      },
      hasError: {
        true: "bg-color-surface-caution-light border-color-border-caution",
        false: "",
      },
    },
    defaultVariants: {
      intent: "primary",
      size: "lg",
      margin: "none",
      hasError: false,
      fullWidth: false,
    },
  }
);

type TextFieldProps = VariantProps<typeof textField>;

interface Props {
  intent?: TextFieldProps["intent"];
  size?: TextFieldProps["size"];
  margin?: TextFieldProps["margin"];
  hasError?: TextFieldProps["hasError"];
  fullWidth?: TextFieldProps["fullWidth"];
  type?: "text" | "email" | "tel";
  modelValue?: string;
}

withDefaults(defineProps<Props>(), {
  intent: "primary",
  size: "lg",
  margin: "none",
  hasError: false,
  fullWidth: true,
  type: "text",
  modelValue: "",
});

const emits = defineEmits<{ (e: "update:modelValue", value: string): void }>();

const onInput = (e: Event) => {
  const target = e.target as HTMLInputElement;
  emits("update:modelValue", target.value);
};

とりあえず実装してみたコンポーネントは下記機能(props)を持たせています。

  • intent : variant、cvaではintentになります。
  • size : サイズ
  • margin : 固定マージン
  • fullWidth : 横幅100%にするかどうか
  • hasError : バリデーションエラースタイル用
  • type : 基本的なtext, email, tel
  • modelValue : コンポーネントでv-modelを使用できるようにするためのもの

ここでデザインパターンごとのスタイルを最終的にtwMerge()でマージしています。

<input
    :class="twMerge(textField({ intent, size, margin, fullWidth, hasError }))"
    ...略
  />

cvaを使ってみて良かったところ

ここで、実装してみたテキストフィールドコンポーネントのコードの部分的に見ていきつつ、cvaを使って良かったところを記載します。

スタイルとマークアップを分離できる

今回cvaを使ったことにより、<template></template>内のhtmlには最小限のスタイル割り当てしか記述する必要がなく、マークアップの見通しがよくなりました。

<input
    :class="twMerge(textField({ intent, size, margin, fullWidth, hasError }))"
    :type="type"
    :value="modelValue"
    @input="onInput"
  />

今回はテキストフィールドのシンプルなコンポーネントですが、これにさらに別の要素が入ってきたり複雑なマークアップが必要なコンポーネントではより重宝しそうです。

また条件分岐を書く必要がなくなり、variantsごとのパターンはcvaでオブジェクトで記述するだけでよくなったこともhtmlの見通しが良くなったことに影響しています。

機能ごとにスタイルを管理できる

const textField = cva(
  "outline-0 pl-4 pr-11 text-base bg-color-surface-primary border-color-border-medium-emphasis focus:shadow-sm disabled:bg-color-surface-disabled disabled:text-color-text-disabled",
  {
    variants: {
      intent: {
        primary: "rounded-lg border",
        secondary: "border-b",
        tertiary: "bg-color-surface-tertiary border-b rounded-sm",
      },
      size: {
        lg: "min-h-[48px]",
        md: "min-h-[40px]",
        sm: "min-h-[36px]",
      },
      margin: {
        none: "",
        sm: "mt-2",
        md: "mt-4",
        lg: "mt-6",
        xl: "mt-8",
      },
      fullWidth: {
        true: "w-full",
        false: "",
      },
      hasError: {
        true: "bg-color-surface-caution-light border-color-border-caution",
        false: "",
      },
    },
    defaultVariants: {
      intent: "primary",
      size: "lg",
      margin: "none",
      hasError: false,
      fullWidth: false,
    },
  }
);

propsを機能ごとに分離していると、cva()を使ったときに機能ごとのスタイル分けが明確になり可読性が上がります。

すっきりしていていいですね✨

propsの型定義が楽

type TextFieldProps = VariantProps<typeof textField>;

interface Props {
  intent?: TextFieldProps["intent"];
  size?: TextFieldProps["size"];
  margin?: TextFieldProps["margin"];
  hasError?: TextFieldProps["hasError"];
  fullWidth?: TextFieldProps["fullWidth"];
  type?: "text" | "email" | "tel";
  modelValue?: string;
}

cvaを使うと、専用の型VariantPropsがあるため、propsの型を自前で書く必要がなくなります。

今回のケースで言うと、intentpropは"primary" | "secondary" | "tertiary"と、cva()の中で定義しているvariant.intentの中のキーを参照して型定義してくれるのが嬉しいです。

例えば運用で新しくquaternaryというvariantが追加された場合でも、cva()内のvariant.intentの中にquaternaryのキーを追加するだけで、intentpropの型も自動的にアップデートしてくれます。

コンポーネントのvariantのアップデートの際にTypeScriptの型のアップデートといった実装者側のメンテナンス工数がなくなるのは嬉しいですね。

Creating variants with the "traditional" CSS approach can become an arduous task; manually matching classes to props and manually adding types.

cva aims to take those pain points away, allowing you to focus on the more fun aspects of UI development.

公式でも上記のように記載されており、「通常のように手動でpropsの型を調整する負担を和らげ、UI実装によりフォーカスできるようになる」と言っている意味がわかりました✨

cva内のクラスの記載方法について

cvaの公式では、各variantのクラスの記法を配列で記載していますが、今回私が実装したように一つの文字列としての記載もよさそうと考えています。

公式から抜粋
const button = cva(["font-semibold", "border", "rounded"], {
  variants: {
    intent: {
      primary: [
        "bg-blue-500",
        "text-white",
        "border-transparent",
        "hover:bg-blue-600",
      ],
      // 略
    }
    // 略
});
こっちでもよさそう
const button = cva("font-semibold border rounder", {
  variants: {
    intent: {
      primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600",
      // 略
    }
    // 略
});

公式でも記載がありますが、cvaは極論、文字列を管理するツールです。

であれば、直感的にUIを構築できることが売りのTailwind CSSを組み合わせるのでは、極力余分な記号([]とか,とか)の入力は避けてスピーディにクラスをバンバン追加して実装していきたいので、私は一つの文字列としての実装がいいなぁと思っています。

「いや、配列の方がいいよ!」と言う方いましたらコメントで教えてください!🥺

ちょっと気になったこと

https://cva.style/docs/examples/other-use-cases

公式のその他のユースケースにも記載がありますが、cvaは要は文字列管理ツールです。

cvaはcssのクラスの管理を第一としていますが、この文字列管理という面で機能を抽象化すればcssクラスの管理以外でも使えそうだなと思っています。

ここら辺も研究していきたいですね✍️

おわりに

cva初めていじってみましたが、使わない時よりもコード全体の可読性が上がっていいツールだなぁと思いました!
まだbeta版なので正式リリースを待つ感じになりますが、正式リリースしたら使っていきたいですね!

Discussion