👌

Next.jsでTailwindを使ったデザインシステムの構築をしよう!

2023/03/31に公開3

今回の記事は、以下の動画の備忘録的なものです。英語ですけど、自動翻訳の字幕でも十分理解できますし13分なのですぐ見れますので、ぜひどうぞ。

また、以下の動画紹介されているcva(Class Variance Authority)に関する記事がQiitaでもZennでもほぼ見かけなかったので、誰かの目に届けばなと思い書いています。

https://youtu.be/T-Zv73yZ_QI

これは、 Next.js Conf 2022の動画で、我々がいつもお世話になっているDiscordのソフトウェアエンジニアの方がTailwind CSSを使ったデザインシステムの構築について話してくれています。

今回紹介するツール

  • Tailwind CSS
  • Clsx
  • Class-Variance-Authority
  • Radix UI

なぜ、Tailwind CSSを使うのか?

MUI、Chakra UI、Mantine などのあらかじめコンポーネントが準備されているUIライブラリを使用していて以下のように思ったことはありませんか?

  • このボタンなんか違うんだよなぁ・・・
  • なんか設定いじりすぎて、原型留めてなくね?
  • ちょっとデザイン変えたいだけなのに上手くいかない・・・

もちろん、UIライブラリが用意してくれている APIやプラグインやカスタマイズ方法などを使って実現できる部分もあるとは思いますが、与えられているデザインシステムからズレたものは実現するのが難しいです。それは、我々がデザインシステムを丸ごと借りていて、オーナーシップを失っている状態だからです。

そのため、デザインシステムの範疇を越える調整を行いたい場合は衝突が起こってしまい、仕方なくglobalのCSSにちまちま上書きしたりするんですよね。(富岡義勇が聞いたら、「デザインシステムの生殺与奪の権利を渡すな!」などと言われそうです笑)

では、どうすれば良いのか?

デザインシステムを一から作るのです。

しかし、「MUIみたいなUIライブラリ一から作れ!」という訳ではなく、プロジェクトに必要なコンポーネントだけを作れば良いんです。よく使われるコンポーネントはかなり限定的なはずです。

デザインシステムの構築に便利なのが、そうTailwind CSSです。

Tailwind CSSの優れている点は、便利なプリセットが用意されていることです。また、これらはあくまでもプリセットであるため、もちろんプロジェクトごとに独自の変更を加えることも可能です。

なので、時々Tailwind CSSでは細かい変更ができないとか主張する人がいますが、設定を変更したり、最近ではarbitrary valuesが利用できますので、細かい微調整も余裕です。

ちなみに、arbitrary valuesは以下のようなものです。

<div class="top-[117px]">
  <!-- ... -->
</div>

コンポーネント設計におけるCSS

というわけで、Tailwind CSSの魅力がある程度わかったところでコンポーネントにおけるCSSの設定について考えてみましょう。

コンポーネント設計では、コンポーネントが受け取ったpropsに応じて条件分岐でCSSを変えるなんてことはよく行われますよね。そんな時に、よく利用されているのがclassnamesclsxです。

これらは、条件付きのCSSを上手く結合してくれるライブラリです。

https://github.com/JedWatson/classnames
https://github.com/lukeed/clsx

具体的には、こんな感じです。

import {clsx} from 'clsx'

const Button = ({intent}) => {
  return(
    <button className={clsx('w-4 h-2 text-white', {
      'bg-blue-400' : intent === 'primary',
      'bg-red-400' : intent === 'danger'
    })}>
      Button
    </button>
  )
}

export default Button

Buttonコンポーネントはintentというpropsを受け取りその値によってCSSが変わります。

しかし、propsと条件分岐が10個も20個も追加されてしまうと、かなりのカオスな状態になってしまいます。

そんな人におすすめしたいのがClass-Variance-Authorityです。

Class-Variance-Authority(cva)とは?

簡単にいうと、variantsを定義して条件分岐をわかりやすくしようというものです。

Figmaとか使ったことある人はVariantsに馴染みがあると思います。

Variantsとは、コンポーネントにおける状態の概念と捉えるとわかりやすいと思います。上記では、intentという状態の概念にprimarydangerという二つのプロパティがあり、それぞれにbg-blue-400bg-red-400などの値が設定されているという感じです。

同じのをやってもメリットが分かりにくいので、intentという状態の概念にsecondaryというプロパティを追加してみます。また、smallmediumlargeという三つのプロパティを持つsizeという状態の概念を追加します。

import { cva } from "class-variance-authority";
 
const buttonStyles = cva('text-white', {
  variants: {
    intent: {
      primary: 'bg-blue-400',
      secondary: 'bg-gray-400',
      danger: 'bg-red-400'
    }
    size: {
      small: 'w-4 h-2',
      medium: 'w-20 h-10',
      large: 'w-40 h-20'
    },
  },
  defaultVariants: {
    intent: "primary",
    size: "medium",
  },
});

const Button = ({intent, size}) => {
  return(
    <button className={buttonStyles({intent, size})}>
      Button
    </button>
  )
}
  
export default Button

Buttonコンポーネントにintentsizeという二つの状態の概念ができました。このコンポーネントは、propsにintentsizeを受け取り、その値によってデザインが適宜変更されるようになります。

これにより、条件分岐の選択肢が増えたとしても非常に管理がしやすい状態になりました。

また、ここでは紹介しませんがVariantPropsが提供されているため、これによりtypescriptにおける型補完の恩恵を享受できます。

詳しくは以下を見ると分かります。

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

Headless UIとの組み合わせ

最近では、Headless UIと呼ばれるStyleを持たない機能面だけのUIライブラリなどが出てきました。特に、tailwindlabsが公開しているheadless uiは当然ながらtailwind cssとの組み合わせが最強なので、ぜひ使いましょう。

https://headlessui.com/

headless uiでは、asというpropにはコンポーネントを使用でき、先ほど紹介したcvaで作成したコンポーネントを指定すると、intent='primary'などが指定できるようになります。

import { Menu } from '@headlessui/react'
import { Button } from '@ui/Button'

const MyMenu = () => {
  return (
    <Menu>
      <Menu.Button as={Button} intent='secondary' size='large'></Menu.Button>
      <Menu.Items>
        <Menu.Item href='hoge1'>hoge1</Menu.Item>
        <Menu.Item href='hoge2'>hoge2</Menu.Item>
      </Menu.Items>
    </Menu>
  )
}

export default MyMenu

このように、headless uiのコンポーネントにcvaで作ったコンポーネントをasで指定するとスタイルを適用できます。

最後に

cvaの良さは伝わったでしょうか?

条件分岐のCSSを指定させる方法として、CSSのクラス名をpropsとして渡すみたいな方法もあったのですが、cvaの方が個人的には良いと思います。もちろん、CSS in JSが嫌だという人はそうなってしまうと思うのですが、CSS in JSに抵抗がない人はぜひ使ってみてほしいなと思います。

やっぱり、コンポーネントの中にスタイルを入れることでメンテナンスはしやすいですしね。また、もちろん条件分岐が少ない場合はclsxとかだけでも十分だと思います。しかし、ガッツリとデザインシステムを構築したいという人には、かなりベストな選択肢なのかなと今のところ思っています。

ここまで読んでくれて、ありがとうございます。

お疲れ様でした。

Discussion

nap5nap5

少しデモを作ってみました。

https://codesandbox.io/p/sandbox/sleepy-wiles-3dr94k?file=%2Fsrc%2Ffeatures%2Fcva-usage%2Fcomponents%2FDemo%2FDemo.tsx

定義側

import { ComponentPropsWithRef, FC, forwardRef } from 'react'

import { cva, cx } from 'class-variance-authority'
import clsx from 'clsx'
import { twMerge } from 'tailwind-merge'

import type { VariantProps } from 'class-variance-authority'
import type { Simplify } from 'type-fest'

export type BoxProps = VariantProps<typeof buttonStyles>
const buttonStyles = cva('w-full rounded-lg px-5 py-2.5 text-white', {
  variants: {
    intent: {
      primary: cx(
        `bg-blue-500 hover:bg-blue-700 disabled:bg-blue-200`,
        `active:border-blue-400 active:ring-blue-400`,
        `outline-none focus:outline-none focus:ring-2`,
        `focus:border-blue-600 focus:ring-blue-600`,
        `focus-within:border-blue-600 focus-within:ring-blue-600`,
        `focus-visible:border-blue-600 focus-visible:ring-blue-600`
      ),
      success: cx(
        `bg-green-500 hover:bg-green-700 disabled:bg-green-200`,
        `active:border-green-400 active:ring-green-400`,
        `outline-none focus:outline-none focus:ring-2`,
        `focus:border-green-600 focus:ring-green-600`,
        `focus-within:border-green-600 focus-within:ring-green-600`,
        `focus-visible:border-green-600 focus-visible:ring-green-600`
      ),
      danger: cx(
        `bg-red-500 hover:bg-red-700 disabled:bg-red-200`,
        `active:border-red-400 active:ring-red-400`,
        `outline-none focus:outline-none focus:ring-2`,
        `focus:border-red-600 focus:ring-red-600`,
        `focus-within:border-red-600 focus-within:ring-red-600`,
        `focus-visible:border-red-600 focus-visible:ring-red-600`
      ),
    },
    size: {
      small: 'text-sm',
      medium: 'text-base',
      large: 'text-lg',
    },
  },
  defaultVariants: {
    intent: 'primary',
    size: 'medium',
  },
})
type Props = Simplify<
  Pick<
    ComponentPropsWithRef<'button'>,
    'tabIndex' | 'type' | 'className' | 'disabled' | 'ref' | 'children'
  > &
    BoxProps
>
const Button: FC<Props> = forwardRef(
  (
    {
      children,
      intent = 'primary',
      size = 'medium',
      type = 'button',
      disabled = false,
      className,
      ...props
    },
    ref
  ) => {
    return (
      <button
        ref={ref}
        type={type}
        className={twMerge(
          buttonStyles({ intent, size }),
          clsx(`${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`),
          className
        )}
        disabled={disabled}
        {...props}
      >
        {children}
      </button>
    )
  }
)

Button.displayName = 'Button'

export default Button

使用側

import Button from '@/features/cva-usage/components/Button'

const Demo = () => {
  return (
    <div className='mx-auto my-12 flex w-full max-w-[20rem] flex-col gap-4'>
      <Button intent={'primary'} size={'large'}>
        Large Primary Button
      </Button>
      <Button intent={'primary'} size={'large'} disabled>
        Large Primary Button Disabled
      </Button>
      <Button intent={'primary'} size={'medium'}>
        Medium Primary Button
      </Button>
      <Button intent={'primary'} size={'small'}>
        Small Primary Button
      </Button>
      <Button intent={'danger'} size={'large'}>
        Large Danger Button
      </Button>
      <Button intent={'danger'} size={'large'} disabled>
        Large Danger Button Disabled
      </Button>
      <Button intent={'danger'} size={'medium'}>
        Medium Danger Button
      </Button>
      <Button intent={'danger'} size={'small'}>
        Small Danger Button
      </Button>
      <Button intent={'success'} size={'large'}>
        Large Success Button
      </Button>
      <Button intent={'success'} size={'large'} disabled>
        Large Success Button Disabled
      </Button>
      <Button intent={'success'} size={'medium'}>
        Medium Success Button
      </Button>
      <Button intent={'success'} size={'small'}>
        Small Success Button
      </Button>
    </div>
  )
}

export default Demo

簡単ですが、以上です。

kapurekakapureka

デモと実践例の紹介ありがとうございます!とても分かりやすかったです!

もはや、cvaの記事を書いていただきたいくらいです笑

shadcn/ui 良いですね!まさに、自分が作ろうかなと思っていたものでした😅