Web開発

Tailwindのクラスを頑張って読みやすくしてみる


前がき

最近、Tailwindを使っているプロジェクトに参画しました。

そこで強く感じたのは、意識して読みやすいようにクラスを当てていかないと、後々しんどくなるということでした。

今回は、案件で得た学びをもとに、Tailwindでクラス名を読みやすくするにはどうすればいいかを模索してみようと思います。

注意

「読みやすさ」の定義は人それぞれだと思います。私が思う「読みやすい」とは以下のことです。

  1. コードの意図を読み手が解釈しやすい
  2. 1が高じて、コードの変更・拡張がしやすい

準備

ChatGPT様にtailwindでスタイリングされた適当なコンポーネントを作ってもらいます。

isSelectable, isEditableという変数をもとにスタイルを変える割とシンプルなコンポーネントになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { useState } from 'react'
import './App.css'


const MyComponent = ({ isEditable, isSelectable }: { isEditable: boolean; isSelectable: boolean }) => {
  return (
    <div
      className={`
        flex flex-col gap-2 border rounded-xl shadow-md transition-all duration-300 ease-in-out 
        sm:p-4 sm:text-sm md:p-6 md:text-base lg:p-8 lg:text-lg xl:p-10 xl:text-xl
        hover:translate-y-1 hover:border-opacity-80
      ${isEditable ? "bg-blue-500 border-blue-500 ring-2 ring-blue-300 shadow-lg hover:bg-blue-100 hover:border-blue-500 hover:ring-4 hover:ring-blue-400" : ""} 
      ${isSelectable ? "bg-gray-300 cursor-pointer hover:scale-105 hover:shadow-2xl hover:bg-gray-100 hover:border-gray-500 hover:ring-2 hover:ring-gray-400 hover:rotate-1" : ""} 
      ${isSelectable && isEditable ? "bg-gradient-to-r from-blue-100 to-gray-100 hover:scale-110 hover:-rotate-1" : ""}`}
    >
      <div>isEditable: {isEditable ? 'true' : 'false'}</div>
      <div>isSelectable: {isSelectable ? 'true' : 'false'}</div>
    </div>
  );
};

function App() {
  const [isEditable, setEditable] = useState(false)
  const [isSelectable, setSelectable] = useState(false)

  return (
    <div className='flex flex-col items-center gap-4'>
      <MyComponent isEditable={isEditable} isSelectable={isSelectable} />
      <div className='flex gap-2'>
        <button onClick={() => setEditable((prev) => !prev)}>toggle editable</button>
        <button onClick={() => setSelectable((prev) => !prev)}>toggle selectable</button>
      </div>
    </div>
  )
}

export default App

テンプレート文字列をやめてみる

上のスタイリングでは二項演算子を用いてクラス名を作っています。isSelectableがfalseの時にはクラス名を指定しなくていいので、ちょっと冗長な感じがします。

classNamesを導入してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
...
import classNames from 'classnames';

...

      className={classNames(
        "flex flex-col gap-2 border rounded-xl shadow-md transition-all duration-300 ease-in-out",
        "sm:p-4 sm:text-sm md:p-6 md:text-base lg:p-8 lg:text-lg xl:p-10 xl:text-xl",
        "hover:translate-y-1 hover:border-opacity-80",
        isEditable && "bg-blue-500 border-blue-500 ring-2 ring-blue-300 shadow-lg hover:bg-blue-100 hover:border-blue-500 hover:ring-4 hover:ring-blue-400",
        isSelectable && "bg-gray-300 cursor-pointer hover:scale-105 hover:shadow-2xl hover:bg-gray-100 hover:border-gray-500 hover:ring-2 hover:ring-gray-400 hover:rotate-1",
        isSelectable && isEditable && "hover:bg-gradient-to-r from-blue-100 to-gray-100 hover:scale-110 hover:-rotate-1"
      )}

classNamesを使うとfalsyな値は無視してくれるので、記述量が減り少しスッキリしましたね。

系統ごとに文字列を分割してみる

上のクラス名は横に長くて正直読みづらいです。

意味のあるまとまりに分けてみましょう。

classnamesは複数の文字列や文字列のリストを渡してもクラス名として解釈してくれて便利です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
  return (
    <div
      className={classNames(
        "flex flex-col gap-2",
        "border rounded-xl shadow-md",
        "transition-all duration-300 ease-in-out",
        "sm:p-4 sm:text-sm",
        "md:p-6 md:text-base",
        "lg:p-8 lg:text-lg",
        "xl:p-10 xl:text-xl",
        "hover:translate-y-1 hover:border-opacity-80",
        isEditable && [
          "bg-blue-500",
          "border-blue-500 ring-2 ring-blue-300 shadow-lg",
          "hover:bg-blue-100 hover:border-blue-500 hover:ring-4 hover:ring-blue-400"
        ],
        isSelectable && [
          "bg-gray-300",
          "cursor-pointer",
          "hover:scale-105 hover:shadow-2xl hover:bg-gray-100 hover:border-gray-500 hover:ring-2 hover:ring-gray-400 hover:rotate-1"
        ],
        isSelectable && isEditable && [
          "bg-gradient-to-r from-blue-100 to-gray-100",
          "hover:scale-110 hover:-rotate-1"
        ],
      )}
    >
      <div>isEditable: {isEditable ? 'true' : 'false'}</div>
      <div>isSelectable: {isSelectable ? 'true' : 'false'}</div>
    </div >
  );
};

上記では、背景色、Flex、ボーダー、テキストなど 私見でまとまりにわけ記述してみました。また、擬似要素はブレイクポイントでも分けています。

これでまた少し読みやすくなった気がしますね。

tailwind-variantを導入してみる

意味のあるまとまりごとに分けたことで、スタイルの意味は受け取りやすくなりましたが、コンポーネントの記述に占めるスタイル行の割合が大きくなってきましたね。スタイルはコンポーネント外に切り出しましょう。またこのタイミングでtailwind-variantも導入してみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { tv } from 'tailwind-variants';

const mycomponent = tv({
  base: [
    "flex flex-col gap-2",
    "border rounded-xl shadow-md",
    "transition-all duration-300 ease-in-out",
    "sm:p-4 sm:text-sm",
    "md:p-6 md:text-base",
    "lg:p-8 lg:text-lg",
    "xl:p-10 xl:text-xl",
    "hover:translate-y-1 hover:border-opacity-80",
  ],
  variants: {
    isEditable: {
      true: [
        "bg-blue-500",
        "border-blue-500 ring-2 ring-blue-300 shadow-lg",
        "hover:bg-blue-100 hover:border-blue-500 hover:ring-4 hover:ring-blue-400"
      ],
    },
    isSelectable: {
      true: [
        "bg-gray-300",
        "cursor-pointer",
        "hover:scale-105 hover:shadow-2xl hover:bg-gray-100 hover:border-gray-500 hover:ring-2 hover:ring-gray-400 hover:rotate-1"
      ]
    },
  },
  compoundVariants: [
    {
      isEditable: true,
      isSelectable: true,
      class: [
        "bg-gradient-to-r from-blue-100 to-gray-100",
        "hover:scale-110 hover:-rotate-1"
      ]
    }
  ],
})

const MyComponent = ({ isEditable, isSelectable }: { isEditable: boolean; isSelectable: boolean }) => {

  return (
    <div
      className={mycomponent({ isEditable, isSelectable })}
    >
      <div>isEditable: {isEditable ? 'true' : 'false'}</div>
      <div>isSelectable: {isSelectable ? 'true' : 'false'}</div>
    </div >
  );
};

MyComponentはだいぶスッキリしましたね。

また、variant・compound variantを使うことでさらに読みやすくなりました。

ヘルパーを導入してみる

上記で終わってもいいのですが、ブレイクポイントやバリアントの繰り返しが気になったのでヘルパー関数を作ってみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
const variants = (variant: string) => (className: string | string[]) => (
  typeof className === 'string'
    ? className
    : className.join(' ')
).split(' ').map((c) => `${variant}:${c}`).join(' ')

const sm = variants('sm')
const md = variants('md')
const lg = variants('lg')
const xl = variants('xl')
const hover = variants('hover')

const mycomponent = tv({
  base: [
    "flex flex-col gap-2",
    "border rounded-xl shadow-md",
    "transition-all duration-300 ease-in-out",
    sm("p-4 text-sm"),
    md("p-6 text-base"),
    lg("p-8 text-lg"),
    xl("p-10 text-xl"),
    hover("translate-y-1 border-opacity-80"),
  ],
  variants: {
    isEditable: {
      true: [
        "bg-blue-500",
        "border-blue-500 ring-2 ring-blue-300 shadow-lg",
        hover("bg-blue-100 border-blue-500 ring-4 ring-blue-400"),
      ],
    },
    isSelectable: {
      true: [
        "bg-gray-300",
        "cursor-pointer",
        hover("scale-105 shadow-2xl bg-gray-100 border-gray-500 ring-2 ring-gray-400 rotate-1"),
      ]
    },
  },
  compoundVariants: [
    {
      isEditable: true,
      isSelectable: true,
      class: [
        "bg-gradient-to-r from-blue-100 to-gray-100",
        hover("scale-110 -rotate-1"),
      ]
    }
  ],
});

クラス名を受け取って、それぞれのクラス名の先頭にバリアントをつけるヘルパー関数を作りました。

これによって、どんな状態のときにどんなスタイルが適用されるかが分かりやすくなりました。

まとめ

最後に、変更の前後を比較してみます。

Before

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { useState } from 'react'
import './App.css'


const MyComponent = ({ isEditable, isSelectable }: { isEditable: boolean; isSelectable: boolean }) => {
  return (
    <div
      className={`
        flex flex-col gap-2 border rounded-xl shadow-md transition-all duration-300 ease-in-out 
        sm:p-4 sm:text-sm md:p-6 md:text-base lg:p-8 lg:text-lg xl:p-10 xl:text-xl
        hover:translate-y-1 hover:border-opacity-80
      ${isEditable ? "bg-blue-500 border-blue-500 ring-2 ring-blue-300 shadow-lg hover:bg-blue-100 hover:border-blue-500 hover:ring-4 hover:ring-blue-400" : ""} 
      ${isSelectable ? "bg-gray-300 cursor-pointer hover:scale-105 hover:shadow-2xl hover:bg-gray-100 hover:border-gray-500 hover:ring-2 hover:ring-gray-400 hover:rotate-1" : ""} 
      ${isSelectable && isEditable ? "bg-gradient-to-r from-blue-100 to-gray-100 hover:scale-110 hover:-rotate-1" : ""}`}
    >
      <div>isEditable: {isEditable ? 'true' : 'false'}</div>
      <div>isSelectable: {isSelectable ? 'true' : 'false'}</div>
    </div>
  );
};

function App() {
  const [isEditable, setEditable] = useState(false)
  const [isSelectable, setSelectable] = useState(false)

  return (
    <div className='flex flex-col items-center gap-4'>
      <MyComponent isEditable={isEditable} isSelectable={isSelectable} />
      <div className='flex gap-2'>
        <button onClick={() => setEditable((prev) => !prev)}>toggle editable</button>
        <button onClick={() => setSelectable((prev) => !prev)}>toggle selectable</button>
      </div>
    </div>
  )
}

export default App
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import { useState } from 'react'
import './App.css'
import { tv } from 'tailwind-variants';

const variants = (variant: string) => (className: string | string[]) => (
  typeof className === 'string'
    ? className
    : className.join(' ')
).split(' ').map((c) => `${variant}:${c}`).join(' ')

const sm = variants('sm')
const md = variants('md')
const lg = variants('lg')
const xl = variants('xl')
const hover = variants('hover')

const mycomponent = tv({
  base: [
    "flex flex-col gap-2",
    "border rounded-xl shadow-md",
    "transition-all duration-300 ease-in-out",
    sm("p-4 text-sm"),
    md("p-6 text-base"),
    lg("p-8 text-lg"),
    xl("p-10 text-xl"),
    hover("translate-y-1 border-opacity-80"),
  ],
  variants: {
    isEditable: {
      true: [
        "bg-blue-500",
        "border-blue-500 ring-2 ring-blue-300 shadow-lg",
        hover("bg-blue-100 border-blue-500 ring-4 ring-blue-400"),
      ],
    },
    isSelectable: {
      true: [
        "bg-gray-300",
        "cursor-pointer",
        hover("scale-105 shadow-2xl bg-gray-100 border-gray-500 ring-2 ring-gray-400 rotate-1"),
      ]
    },
  },
  compoundVariants: [
    {
      isEditable: true,
      isSelectable: true,
      class: [
        "bg-gradient-to-r from-blue-100 to-gray-100",
        hover("scale-110 -rotate-1"),
      ]
    }
  ],
});

const MyComponent = ({ isEditable, isSelectable }: { isEditable: boolean; isSelectable: boolean }) => {

  return (
    <div
      className={mycomponent({ isEditable, isSelectable })}
    >
      <div>isEditable: {isEditable ? 'true' : 'false'}</div>
      <div>isSelectable: {isSelectable ? 'true' : 'false'}</div>
    </div >
  );
};

function App() {
  const [isEditable, setEditable] = useState(false)
  const [isSelectable, setSelectable] = useState(false)

  return (
    <div className='flex flex-col items-center gap-4'>
      <MyComponent isEditable={isEditable} isSelectable={isSelectable} />
      <div className='flex gap-2'>
        <button onClick={() => setEditable((prev) => !prev)}>toggle editable</button>
        <button onClick={() => setSelectable((prev) => !prev)}>toggle selectable</button>
      </div>
    </div>
  )
}

export default App

記述量は長くなりましたが、私には後者の方がコードの意図を取りやすく、拡張もしやすいと感じています。

終わりに

今回はTailwindを利用するときの読みやすいクラスの指定方法を模索してみました。

バリアントのグルーピングについては今後公式がサポートしてくれると嬉しいなと思っています。

(前々から議論はされているみたいですし、プラグインも散見されます。)

現時点ではこの書き方に落ち着いていますが、正直どういう書き方が最適かは今も模索中です。

「こういう書き方いいよ!」などあればQiita, Zenn, SNSなどでどんどん発信していただけると嬉しいです。