〰️

【Tailwind CSS】Tailwind VariantsでTailwind CSSを次のレベルへ引き上げよう

2024/01/20に公開

はじめに

今回は、Tailwind CSSを次のレベルにするためにTailwind Variantsによるスタイリングをしようという内容です。

Tailwind CSSは書きやすいですし、かなり使い心地がいいですが、ある点についてのみ圧倒的な短所があります。
それが「クラス名長すぎ問題」です。
私はよく以下のようなことを感じたりします。

おそらく、Tailwind CSSを使った人がある方なら一度は思ったことあるのではないでしょうか。

そんなTailwind CSSの短所を解決できると感じたものがTailwind Variantsです。

Tailwind Variantsとは

以下は、ドキュメントから抜粋したTailwind Variantsの定義です。

Tailwind VariantsとはTailwind CSSの機能とファーストクラスのVariant APIを組み合わせたライブラリです

https://www.tailwind-variants.org/

ここで登場した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>

https://stitches.dev/

これまたドキュメントからですが、以下のような特徴があるようです。

  • slots・responsive variants・components compositionなどの豊富な機能
  • TypeScriptにより完全に型付けされている
  • 特定のフレームワークに依存しないユーティリティ性

それでは実際の実装を見て、利点を感じてみましょう。

Tailwind Variantsを使ってみよう

それでは、早速、プロジェクトにTailwind Variantsを導入しましょう。

Tailwind Variantsを導入

インストールに当たり、前提としてTailwind CSSがインストールされいる必要があります。

Tailwind CSSがインストールされていない場合、以下のドキュメントに従い、Tailwind CSSをインストールしてください。
https://tailwindcss.com/docs/installation

Tailwind CSSが導入済み・インストールできた方は以下ドキュメントに従い、Tailwind Variantsをインストールしましょう。

fish
yarn add tailwind-variants

https://www.tailwind-variants.org/docs/getting-started

インストールができたら、早速、実装してみましょう。

基本の使い方

以下は、ドキュメントのIntroductionにあるbuttonをコンポーネント化したものです。

https://www.tailwind-variants.org/docs/introduction

Button.tsx
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-smbg-purple-500 text-whiteが適用される)
  • compoundVariants: 複数のプロパティに対して、同じスタイルを適用させられる(上記のコードの場合、sizeがsmかmdのときにpx-3 py-1が適用される)
  • defaultVariants: 引数に指定がない場合に適用されるスタイル

基本的には上記のようにクラス名を生成し、JSXで各タグのclassNameで呼び出して使います。

基本の実装だけでも、JSXが汚れていないのがわかりますし、スタイルが宣言的なので、コードの見通しがよく、一目でどんなスタイルが当てられるかがわかると思います。

propsでスタイルを当てる場合

ただ、基本の使い方だと、コンポーネントを親コンポーネントで呼び出したい場合、柔軟性に欠けます。
しかし、Tailwind Variantsはそのあたりもカバーしており、variantspropsとして渡すための型(VariantProps)も提供してくれます。

https://www.tailwind-variants.org/docs/typescript

Button.tsx
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の値を指定する際に補完が効きますし、カーソルホバーすれば、どのような型が適用されているかもわかります。

page.tsx
import { Button } from './components/Button'

const Home = () => {
  return (
    <main className="h-screen w-screen">
      <Button size="lg" color="secondary" />
    </main>
  )
}

export default Home

レスポンシブ対応

さらにレスポンシブにスタイルを適用することもできます。

https://www.tailwind-variants.org/docs/introduction#responsive-variants

以下のようにresponsiveVariantsを定義することで、画面サイズに合わせて、適用させるスタイルを変更することができます。

注意点として、公式ドキュメントではxsから指定がありますが、initialxsから考えるため、xsは不要です。(型エラーが出ます。)

Button.tsx
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.tswithTV()を使用する必要があります。

tailwind.config.ts
+ 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のようにbaseVariantssm:text-smxl:text-lgなども適用できるので、可読性を考慮してみた結果であったり、実装要件に合わせて使いわけることが可能です。

複数のコンポーネントに一度にスタイルを設定

通常のプロジェクトでは、1つ1つ定数としてtv()を設定するのは、かなり手間です。Tailwind Variantsはそれも補う機能があり、その機能がslotsです。

https://www.tailwind-variants.org/docs/introduction#split-components-into-multiple-slots

slotsでの実装はtv()で定義することは変わらず、プロパティとしてslotsを定義し、slots内にbasewrapperなど任意のプロパティ名を定義して、各種スタイルを記述します。

あとは、分割代入でcard()slotsのプロパティを宣言し、スタイルを当てたいコンポーネントのclassNameに適用させていきます。

Card.tsx
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 VariantsOverrides機能で対応できます。

https://www.tailwind-variants.org/docs/introduction#overrides

例えば、以下のケースだと、「このボタンだけ少し大きくしたい」というケースに対応しています。

Button.tsx
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()の引数にオブジェクトでclassclassNameを渡すことでスタイルの上書きが可能です。
上記の場合、baseで指定されているpaddingpy-2 px-4に上書きしています。

スタイルを引き継いで上書き

overridesと似ていますが、以下のようなケースに対応できます。
「ボタンが2つあり、1つ目のボタンと基本は同じスタイルだけど、一部分だけ違うスタイルを当てたい。ただし、overridesのようにJSX内での呼び出しでなく、tv()の宣言で実装したい」

そのような場合には、extendsを使用します。
https://www.tailwind-variants.org/docs/introduction#components-composition

Button.tsx
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という機能について解説します。

https://www.tailwind-variants.org/docs/slots

Compound slots

復習になりますが、slotsは複数のコンポーネントに対するスタイルを定義できるものでしたよね?
そのため、複数のslotsを定義していると、「この2つのslotsには同じスタイルを定義したい」という場面に遭遇すると思います。

そこで有用なのがCompound slotsという機能です。

https://www.tailwind-variants.org/docs/slots#compound-slots

これを使用することで、一度に複数のslotsにスタイルを適用できるので、一つ一つのslotsに対して同じスタイルの記述をしなくて済みます。

私の実装と公式の例を合わせてみると、より理解できると思います。

Header.tsx
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>
  )
}

私の場合は、signUpsignInという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'
  },
],

この場合、logosingUpsignInには、それぞれ以下のスタイルが適用されます。

  • 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の組み合わせ

最後に、この記事で最も実践的な内容になると思われる実装を記述します。
それがSlotsVariantsを組み合わせたスタイリングの定義になります。

https://www.tailwind-variants.org/docs/slots#slots-with-variants

公式ドキュメントはuseStateなどを用いた例を実装しており、こちらのほうが、より実践的ですが、少し複雑なため、私の方は簡素化して紹介できればと思います。

Header.tsx
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の部分ですが、それぞれ順に見ていきましょう。
typefluidが指定されていれば、slotscontainermax-w-3xlmax-w-fullに上書きされます。

その他も同様の方法でネストを追っていくと、どんなスタイルが当てられるかがわかります。

hiddenでは、trueの際にactionshiddenになり、falseではflexとなります。
また、レスポンシブ対応されているので、実装部分も見ると画面幅が小さいときにhidden.trueのスタイルが当てられることがわかります。

colorも同様に、primarysecondaryという値によってsignUpsignInに当てられるスタイルが変わるということがわかります。

これを親で以下のように呼び出すと、props.colorにはsecondaryが指定されているため、signUpsignInにはcolor.secondaryのスタイルが当てられます。

props.typeundefinedとなるので、containermax-w-3xlのままでmax-w-fullに上書きされません。

page.tsx
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を導入しない・できない理由はクラス名が長くなるからの一点の短所がかなり大きい理由可と思っているので、それを補えるライブラリを使うことができれば、個人開発だけでなく、企業のプロジェクトにも導入のハードルを下げることができるのではないかと思ってもいます。

最後までお読みいただき、ありがとうございました!

参考文献

https://www.tailwind-variants.org/
https://www.tailwind-variants.org/docs/
https://stitches.dev/
https://tailwindcss.com/docs/installation
https://zenn.dev/yend724/articles/20230603-wgnqrgmj8kymzpev#tailwind-variantsとは

Discussion