【Tailwind CSS】Tailwind VariantsでTailwind CSSを次のレベルへ引き上げよう
はじめに
今回は、Tailwind CSSを次のレベルにするためにTailwind Variantsによるスタイリングをしようという内容です。
Tailwind CSSは書きやすいですし、かなり使い心地がいいですが、ある点についてのみ圧倒的な短所があります。
それが「クラス名長すぎ問題」です。
私はよく以下のようなことを感じたりします。
おそらく、Tailwind CSSを使った人がある方なら一度は思ったことあるのではないでしょうか。
そんなTailwind CSSの短所を解決できると感じたものがTailwind Variantsです。
Tailwind Variantsとは
以下は、ドキュメントから抜粋したTailwind Variantsの定義です。
Tailwind VariantsとはTailwind CSSの機能とファーストクラスのVariant APIを組み合わせたライブラリです
ここで登場したVariant APIという言葉ですが、これはStitchesというCSS in JSライブラリの影響を受けたもので、Tailwind Variantsはその考え方をTailwind CSSに輸入したライブラリです。
Stitchesでは以下のようにstyled()を使用してvariantsを追加できます。
要するに、このvariantsの考え方をTailwind CSS製にしたライブラリということです。
const Button = styled('button', {
  // base styles
  variants: {
    variant: {
      primary: {
        // primary styles
      },
      secondary: {
        // secondary styles
      },
    },
  },
});
<Button>Button</Button>
<Button variant="primary">Primary button</Button>
これまたドキュメントからですが、以下のような特徴があるようです。
- slots・responsive variants・components compositionなどの豊富な機能
- TypeScriptにより完全に型付けされている
- 特定のフレームワークに依存しないユーティリティ性
それでは実際の実装を見て、利点を感じてみましょう。
Tailwind Variantsを使ってみよう
それでは、早速、プロジェクトにTailwind Variantsを導入しましょう。
Tailwind Variantsを導入
インストールに当たり、前提としてTailwind CSSがインストールされいる必要があります。
Tailwind CSSがインストールされていない場合、以下のドキュメントに従い、Tailwind CSSをインストールしてください。
Tailwind CSSが導入済み・インストールできた方は以下ドキュメントに従い、Tailwind Variantsをインストールしましょう。
yarn add tailwind-variants
インストールができたら、早速、実装してみましょう。
基本の使い方
以下は、ドキュメントのIntroductionにあるbuttonをコンポーネント化したものです。
import { tv } from 'tailwind-variants'
const button = tv({
  base: 'font-medium bg-blue-500 text-white rounded-full active:opacity-80',
  variants: {
    color: {
      primary: 'bg-blue-500 text-white',
      secondary: 'bg-purple-500 text-white',
    },
    size: {
      sm: 'text-sm',
      md: 'text-base',
      lg: 'px-4 py-3 text-lg',
    },
  },
  compoundVariants: [
    {
      size: ['sm', 'md'],
      class: 'px-3 py-1',
    },
  ],
  defaultVariants: {
    size: 'md',
    color: 'primary',
  },
})
export const Button = () => {
  return (
    <button
      type="button"
      className={button({ size: 'sm', color: 'secondary' })}
    >
      Click me
    </button>
  )
}
基本的には、tv()を使用してクラス名を作成していきます。
それぞれのプロパティは以下のように機能します。
- 
base: 常に適用されるスタイルを定義
- 
variants: プロパティを宣言し、引数で該当するプロパティが渡された際に適用されるスタイルを定義(上記のコードの場合、sizeがsmでcolorがsecondaryなのでtext-smとbg-purple-500 text-whiteが適用される)
- 
compoundVariants: 複数のプロパティに対して、同じスタイルを適用させられる(上記のコードの場合、sizeがsmかmdのときにpx-3 py-1が適用される)
- 
defaultVariants: 引数に指定がない場合に適用されるスタイル
基本的には上記のようにクラス名を生成し、JSXで各タグのclassNameで呼び出して使います。
基本の実装だけでも、JSXが汚れていないのがわかりますし、スタイルが宣言的なので、コードの見通しがよく、一目でどんなスタイルが当てられるかがわかると思います。
propsでスタイルを当てる場合
ただ、基本の使い方だと、コンポーネントを親コンポーネントで呼び出したい場合、柔軟性に欠けます。
しかし、Tailwind Variantsはそのあたりもカバーしており、variantsをpropsとして渡すための型(VariantProps)も提供してくれます。
import React, { FC } from 'react'
import { VariantProps, tv } from 'tailwind-variants'
const button = tv({
  base: 'font-medium bg-blue-500 text-white rounded-full active:opacity-80',
  variants: {
    color: {
      primary: 'bg-blue-500 text-white',
      secondary: 'bg-purple-500 text-white',
    },
    size: {
      sm: 'text-sm',
      md: 'text-base',
      lg: 'px-4 py-3 text-lg',
    },
  },
  compoundVariants: [
    {
      size: ['sm', 'md'],
      class: 'px-3 py-1',
    },
  ],
  defaultVariants: {
    size: 'md',
    color: 'primary',
  },
})
type ButtonProps = VariantProps<typeof button>
export const Button: FC<ButtonProps> = (props) => {
  return (
    <button
      type="button"
      className={button({ size: props.size, color: props.color })}
    >
      Click Me
    </button>
  )
}
上記のようにButtonコンポーネントのpropsに型付けすることで、親コンポーネントでpropsによるスタイルのしても可能となります。
当然、型付けされているので、親コンポーネントでpropsの値を指定する際に補完が効きますし、カーソルホバーすれば、どのような型が適用されているかもわかります。
import { Button } from './components/Button'
const Home = () => {
  return (
    <main className="h-screen w-screen">
      <Button size="lg" color="secondary" />
    </main>
  )
}
export default Home
レスポンシブ対応
さらにレスポンシブにスタイルを適用することもできます。
以下のようにresponsiveVariantsを定義することで、画面サイズに合わせて、適用させるスタイルを変更することができます。
注意点として、公式ドキュメントではxsから指定がありますが、initialがxsから考えるため、xsは不要です。(型エラーが出ます。)
import React from 'react'
import { tv } from 'tailwind-variants'
const button = tv(
  {
    base: 'font-medium bg-blue-500 text-white rounded-full active:opacity-80',
    variants: {
      color: {
        primary: 'bg-blue-500 hover:bg-blue-700',
        secondary: 'bg-purple-500 hover:bg-purple-700',
        success: 'bg-green-500 hover:bg-green-700',
        error: 'bg-red-500 hover:bg-red-700',
      },
    },
  },
  { responsiveVariants: ['sm', 'md', 'lg'] },
)
export const Button = () => {
  return (
    <button
      type="button"
      className={button({
        color: {
          initial: 'primary',
          sm: 'secondary',
          md: 'success',
          lg: 'error',
        },
      })}
    >
      Click Me
    </button>
  )
}
ただし、responsiveVariantsを使用する際は、tailwind.config.tsでwithTV()を使用する必要があります。
+ import { withTV } from 'tailwind-variants/dist/transformer.js'
  import type { Config } from 'tailwindcss'
  const config = {
    content: [
      './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
      './src/components/**/*.{js,ts,jsx,tsx,mdx}',
      './src/app/**/*.{js,ts,jsx,tsx,mdx}',
    ],
    theme: {
      extend: {
        backgroundImage: {
          'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
          'gradient-conic':
            'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
        },
      },
    },
    plugins: [],
  } satisfies Config
+ export default withTV(config)
この設定を加えることでresponsiveVariantsを使用することができます。
もちろん、通常のTailwind CSSのようにbaseやVariantsにsm:text-smやxl:text-lgなども適用できるので、可読性を考慮してみた結果であったり、実装要件に合わせて使いわけることが可能です。
複数のコンポーネントに一度にスタイルを設定
通常のプロジェクトでは、1つ1つ定数としてtv()を設定するのは、かなり手間です。Tailwind Variantsはそれも補う機能があり、その機能がslotsです。
slotsでの実装はtv()で定義することは変わらず、プロパティとしてslotsを定義し、slots内にbaseはwrapperなど任意のプロパティ名を定義して、各種スタイルを記述します。
あとは、分割代入でcard()のslotsのプロパティを宣言し、スタイルを当てたいコンポーネントのclassNameに適用させていきます。
import React from 'react'
import { tv } from 'tailwind-variants'
const card = tv({
  slots: {
    base: 'md:flex bg-slate-100 rounded-xl p-8 md:p-0 dark:bg-gray-900',
    avatar:
      'w-24 h-24 md:w-48 md:h-auto md:rounded-none rounded-full mx-auto drop-shadow-lg',
    wrapper: 'flex-1 pt-6 md:p-8 text-center md:text-left space-y-4',
    description: 'text-md font-medium',
    infoWrapper: 'font-medium',
    name: 'text-sm text-sky-500 dark:text-sky-400',
    role: 'text-sm text-slate-700 dark:text-slate-500',
  },
})
export const Card = () => {
  const { base, avatar, wrapper, description, infoWrapper, name, role } = card()
  return (
    <figure className={base()}>
      <img
        className={avatar()}
        src="/avatar.png"
        alt=""
        width="384"
        height="512"
      />
      <div className={wrapper()}>
        <blockquote>
          <p className={description()}>
            “Tailwind variants allows you to reduce repeated code in your
            project and make it more readable. They fixed the headache of
            building a design system with TailwindCSS.”
          </p>
        </blockquote>
        <figcaption className={infoWrapper()}>
          <div className={name()}>Zoey Lang</div>
          <div className={role()}>Full-stack developer, NextUI</div>
        </figcaption>
      </div>
    </figure>
  )
}
Tailwind Variantsのスタイルを上書き
実装をしていると、「この部分だけ、別のスタイルを当てて上書きしたい」なんていう場面が結構あります。
そんな場合にはTailwind VariantsのOverrides機能で対応できます。
例えば、以下のケースだと、「このボタンだけ少し大きくしたい」というケースに対応しています。
import React from 'react'
import { tv } from 'tailwind-variants'
const button = tv({
  base: 'font-semibold text-white py-1 px-3 rounded-full active:opacity-80',
  variants: {
    color: {
      primary: 'bg-blue-500 hover:bg-blue-700',
      secondary: 'bg-purple-500 hover:bg-purple-700',
      success: 'bg-green-500 hover:bg-green-700',
      error: 'bg-red-500 hover:bg-red-700',
    },
  },
  defaultVariants: {
    color: 'primary',
  },
})
export const Button = () => {
  return (
    <button type="button" className={button({ class: 'py-2 px-4' })}>
      Click Me
    </button>
  )
}
overridesではbutton()の引数にオブジェクトでclassかclassNameを渡すことでスタイルの上書きが可能です。
上記の場合、baseで指定されているpaddingをpy-2 px-4に上書きしています。
スタイルを引き継いで上書き
overridesと似ていますが、以下のようなケースに対応できます。
「ボタンが2つあり、1つ目のボタンと基本は同じスタイルだけど、一部分だけ違うスタイルを当てたい。ただし、overridesのようにJSX内での呼び出しでなく、tv()の宣言で実装したい」
そのような場合には、extendsを使用します。
import React from 'react'
import { tv } from 'tailwind-variants'
const baseButton = tv({
  base: 'font-semibold, dark:text-white py-1 px-3 rounded-full active:opacity-80 bg-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-800',
})
const buyButton = tv({
  extend: baseButton,
  base: 'text-sm text-white rounded-lg shadow-lg uppercase tracking-wider bg-blue-500 hover:bg-blue-600 shadow-blue-500/50 dark:bg-blue-500 dark:hover:bg-blue-600',
})
export const Button = () => {
  return (
    <div className="flex gap-3">
      <button type="button" className={baseButton()}>
        Button
      </button>
      <button type="button" className={buyButton()}>
        Buy button
      </button>
    </div>
  )
}
上記のようにすることでbaseButtonを引き継いでのスタイリングができます。
公式ドキュメントでは配列でスタイルを定義していますが、文字列でも配列でもどちらでも指定可能です。
最後にslotsはよく使うことになると思うので、実践的な内容としてVariantsとの組み合わせやComoound slotsという機能について解説します。
【実践編】 Slotsの機能を使ってみよう
実践編では簡易的なヘッダーのコンポーネントを使用して解説します。
まずは、複数のslotsに同じスタイルを適用できるCompound slotsという機能について解説します。
Compound slots
復習になりますが、slotsは複数のコンポーネントに対するスタイルを定義できるものでしたよね?
そのため、複数のslotsを定義していると、「この2つのslotsには同じスタイルを定義したい」という場面に遭遇すると思います。
そこで有用なのがCompound slotsという機能です。
これを使用することで、一度に複数のslotsにスタイルを適用できるので、一つ一つのslotsに対して同じスタイルの記述をしなくて済みます。
私の実装と公式の例を合わせてみると、より理解できると思います。
import React from 'react'
import { tv } from 'tailwind-variants'
const header = tv(
  {
    slots: {
      root: 'bg-red-300',
      container: 'max-w-3xl mx-auto bg-red-400 flex justify-between',
      logo: '',
      actions: 'flex gap-4',
      signUp: 'bg-green-500',
      signIn: 'bg-blue-500',
    },
    compoundSlots: [
      {
        slots: ['signUp', 'signIn'],
        class: 'py-1 px-4',
      },
    ],
  }
)
export const Header = () => {
  const { root, container, logo, actions, signUp, signIn } = header()
  
  return (
    <header className={root()}>
      <div className={container()}>
        <div className={logo()}>ロゴ</div>
        <div className={actions()}>
          <button type="button" className={signUp()}>
            新規登録
          </button>
          <button type="button" className={signIn()}>
            ログイン
          </button>
        </div>
      </div>
    </header>
  )
}
私の場合は、signUpとsignInという2つのslotsに対して、py-1 px-4というスタイルを定義しています。
また、compoundSlotsは配列で定義するため、重複するスタイルがある場合、次々に定義することができます。
例えば、以下のようにすることができます。
compoundSlots: [
  {
    slots: ['signUp', 'signIn'],
    class: 'py-1 px-4',
  },
  {
    slots: ['logo', 'signUp', 'signIn'],
    class: 'hover:opacity-50 rounded-full text-lg font-bold'
  },
],
この場合、logo・singUp・signInには、それぞれ以下のスタイルが適用されます。
- logo: hover:opacity-50 rounded-full text-lg font-bold
- signUp: bg-green-500 py-1 px-4 hover:opacity-50 rounded-full text-lg font-bold
- signIn: bg-blue-500 py-1 px-4 hover:opacity-50 rounded-full text-lg font-bold
このようにCompound slotsを使うことで記述の冗長性を防ぐことができたり、リファクタリングがしやすくなります。
SlotsとVariantsの組み合わせ
最後に、この記事で最も実践的な内容になると思われる実装を記述します。
それがSlotsとVariantsを組み合わせたスタイリングの定義になります。
公式ドキュメントはuseStateなどを用いた例を実装しており、こちらのほうが、より実践的ですが、少し複雑なため、私の方は簡素化して紹介できればと思います。
import React, { FC } from 'react'
import { VariantProps, tv } from 'tailwind-variants'
const header = tv(
  {
    slots: {
      root: 'bg-red-300',
      container: 'max-w-3xl mx-auto bg-red-400 flex justify-between',
      logo: '',
      actions: 'flex gap-4',
      signUp: 'bg-green-500',
      signIn: 'bg-blue-500',
    },
    variants: {
      type: {
        fluid: {
          container: 'max-w-full',
        },
      },
      hidden: {
        true: {
          actions: 'hidden',
        },
        false: {
          actions: 'flex',
        },
      },
      color: {
        primary: {
          signUp:
            'bg-blue-500 hover:bg-blue-700 shadow-lg rounded-full text-white dark:bg-blue-900',
          signIn:
            'bg-blue-500 hover:bg-blue-700 shadow-lg rounded-full text-white dark:bg-blue-900',
        },
        secondary: {
          signUp:
            'bg-purple-500 hover:bg-purple-500 shadow-lg rounded-full text-white dark:bg-purple-900',
          signIn:
            'bg-purple-500 hover:bg-purple-500 shadow-lg rounded-full text-white dark:bg-purple-900',
        },
      },
    },
    compoundSlots: [
      {
        slots: ['signUp', 'signIn'],
        class: 'py-1 px-4',
      },
    ],
  },
  { responsiveVariants: ['sm'] },
)
type HeaderVariants = VariantProps<typeof header>
export const Header: FC<HeaderVariants> = ({ type, color }) => {
  const { root, container, logo, actions, signUp, signIn } = header({
    type,
    color,
    hidden: {
      initial: true,
      sm: false,
    },
  })
  return (
    <header className={root()}>
      <div className={container()}>
        <div className={logo()}>ロゴ</div>
        <div className={actions()}>
          <button type="button" className={signUp()}>
            新規登録
          </button>
          <button type="button" className={signIn()}>
            ログイン
          </button>
        </div>
      </div>
    </header>
  )
}
まずは、Tailwind Variantsの定義内容を解説します。
各slotsの定義の部分は簡単ですよね。
次にvariantsの部分ですが、それぞれ順に見ていきましょう。
typeにfluidが指定されていれば、slotsのcontainerのmax-w-3xlがmax-w-fullに上書きされます。
その他も同様の方法でネストを追っていくと、どんなスタイルが当てられるかがわかります。
hiddenでは、trueの際にactionsがhiddenになり、falseではflexとなります。
また、レスポンシブ対応されているので、実装部分も見ると画面幅が小さいときにhidden.trueのスタイルが当てられることがわかります。
colorも同様に、primaryとsecondaryという値によってsignUp・signInに当てられるスタイルが変わるということがわかります。
これを親で以下のように呼び出すと、props.colorにはsecondaryが指定されているため、signUp・signInにはcolor.secondaryのスタイルが当てられます。
props.typeはundefinedとなるので、containerはmax-w-3xlのままでmax-w-fullに上書きされません。
import { Header } from './components/Header'
const Home = () => {
  return (
    <div>
      <Header color="secondary" />
    </div>
  )
}
export default Home
このようにslotsを使いこなすことができれば、複雑なスタイルも宣言的に定義することができ、JSXをきれいに保つことがでるため、コンポーネントのロジックやStateなどの状態が負いやすくなります。
また、それだけでなく、Tailwind CSSのクラスも宣言的な実装になるため、何にどのスタイルが当たっているかをtvを見るだけでわかるようになります。
以上でTailwind Variantsの大体の機能の解説はできたかなと思います。
解説していない機能も少なからずありますので、全てを知りたい方は公式ドキュメントを読んでみてください。
おわりに
非常に便利なライブラリだと思っていただけたのではないでしょうか。
- tvの宣言部分を見れば適用されるスタイルがわかる
- Tailwind CSSのクラス・JSXの可読性が上がる = 保守性が上がる
- レスポンシブ対応も簡単
- Stateなどとの組み合わせても可読性がそこまで落ちない
それ以外にもメリットは個人個人で感じたものがあるかと思います。
私、個人としてはTailwind Variantsを今後も使って開発していきたいと思いましたし、Tailwind CSSを導入しない・できない理由はクラス名が長くなるからの一点の短所がかなり大きい理由可と思っているので、それを補えるライブラリを使うことができれば、個人開発だけでなく、企業のプロジェクトにも導入のハードルを下げることができるのではないかと思ってもいます。
最後までお読みいただき、ありがとうございました!
参考文献





Discussion