😊

CSS Module版shadcn/uiがほしいという話

2024/10/28に公開
2

はじめに

shadcn/ui の CSS Module 版がほしい、と思った経緯と、すこし自分でやってみたという内容を記事にしてみました。

どなたかの何かの役に立つ情報になると嬉しいです。

shadcn/ui の設計は素晴らしい

shadcn/ui は React のコンポーネント集です。

shadcn/ui の設計思想が素晴らしい、という主張は既にいろいろな方がネット上で述べていますが、
個人的にはその理由は以下の2点にあると考えています。

コンポーネントのソースコードが直接配布される

公式の説明

This is NOT a component library. It's a collection of re-usable components that you can copy and paste into your apps.

とある通り、shadcn/ui はコンポーネントライブラリではありません。

これの意図するところとしては、通常のコンポーネントライブラリのようにコンポーネントをnpm installして使う形ではなく、
shadcn/ui が用意するコンポーネントのソースコード自体を取得し、それを使う、という点です。

この特徴によって、コンポーネントの再利用/拡張が容易になるという点と、
コンポーネントライブラリ自体のバージョンアップに追従する必要性が低くなる、という利点があります。

コンポーネント設計の責務が明確

shadcn/ui ではコンポーネントの要素を

  1. 振る舞い(behaviors)
  2. 構造(structure)
  3. 視覚的なスタイル(visual style)

の 3 つに分け、明確に区別しています。
それぞれが何を表しているかは以下になります。

  • 振る舞い(behaviors)
    • フォーカス/ブラー状態における処理、キーボードによるナビゲーション、WAI-ARIA への対応など
    • 主に HTML 要素/属性, JavaScript で実装される部分に当たる
  • 構造(structure)
    • コンポーネントのスタイルのうち、細かい見た目のデザインを調整する部分を除いた部分
    • おそらく、、、概念としては OOCSS の structure と skin の分離のうち structure に当たるものに近いと思われる
  • 視覚的なスタイル(visual style)
    • コンポーネントのスタイルのうち、細かい見た目のデザインを調整する部分。variants とも呼ばれる。
    • これもおそらく、概念としては OOCSS の skin に近いと思われる

shadcn/ui では「振る舞い(behaviors)と構造(structure)」と「視覚的なスタイル(visual style)」はそれぞれ以下の技術要素で実装されています。

  • 振る舞い(behaviors)と構造(structure)
  • 視覚的なスタイル(visual style)
    • Tailwind CSSCVA (variants の実装を容易にするライブラリ) の組み合わせで実装

(引用:shadcn/ui の内部構造を探る, The anatomy of shadcn/ui)

このようにコンポーネントの責務が明確に分けて設定されているため、
たとえば shadcn/ui のコンポーネントの見た目のみを独自カスタマイズしたい場合は Tailwind と CVA の部分だけを変更すれば良くなり、高い拡張性が実現されています。

でも tailwind じゃなくて CSS Module がいい

このような感じで素晴らしい shadcn/ui ですが、1点だけ気になる点があります。

それはTailwind 依存という点です。

もちろん、Tailwind が良くないフレームワークだと言うつもりは全くなく、現在の FE 開発で広く使われている素晴らしいソリューションであると思います。

ただ一方で、私のような専門が FE 開発ではないエンジニアにとっては、
「これから自分が使っていく技術がこの先も使われ続けるか」という観点はけっこう大事であり、その意味では Tailwind より素の CSS を使った CSS Module などの技術に軍配が上がると考えています。

(また、それでも私が Tailwind CSS ではなく、CSS Modules を推す理由 - Qiitaの記事で述べられている、

Tailwind は、ありがちな CSS にまつわる問題を解決し、最低品質は保障してくれるかもしれない一方で、必ずしも『理想のフロントエンド』への道筋にあるものではないのかなと思っています。

という部分に個人的に納得した、という面もあります。)

ということで、shadcn/ui は素晴らしいけど Tailwind じゃなくて CSS module が使いたい、となりました。

さてどうする。

shadcn/ui の拡張性が高いって言うなら自分で書き換えればいいじゃん

上記で散々 shadcn/ui は拡張性が高くて素晴らしい、と述べてきたので責任を持って自分で CSS Module 版に書き換えます。
(といっても全部のコンポーネントは到底無理なので簡素なBadgeコンポーネントだけをやってみます)

書き換えが発生するのは以下の2点になります。

  1. Tailwind のユーティリティクラスの部分を CSS Module に書き換え
  2. shadcn/ui のユーティリティ関数cnを Tailwind 依存しないように書き換え

Badgeコンポーネントの書き換え前のコードは以下の通りです。
(参照元)

Badge.tsx
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";

const badgeVariants = cva(
  "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
  {
    variants: {
      variant: {
        default:
          "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
        secondary:
          "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
        destructive:
          "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
        outline: "text-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
);

export interface BadgeProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
  return (
    <div className={cn(badgeVariants({ variant }), className)} {...props} />
  );
}

export { Badge, badgeVariants };

Tailwind のユーティリティクラスの書き換え

Tailwind のユーティリティクラスの箇所は以下のように書き換えます。

CVA + CSS Module の実装は CVA 公式のこちらの実装サンプルを参考にしています。

Badge.tsx
// 省略

import style from "./Badge.module.css";

const badgeVariants = cva(style.badge, {
  variants: {
    variant: {
      default: style.default,
      secondary: style.secondary,
      destructive: style.destructive,
      outline: style.outline,
    },
  },
  defaultVariants: {
    variant: "default",
  },
});

// 省略

CSS Module の方は1個ずつチマチマ変換していきます。

Badge.module.css
.badge {
  /* inline-flex */
  display: inline-flex;
  /* items-center */
  align-items: center;
  /* rounded-md  */
  border-radius: 0.375rem; /* 6px */
  /* border  */
  border-width: 1px;
  /* px-2.5  */
  padding-left: 0.625rem; /* 10px */
  padding-right: 0.625rem; /* 10px */
  /* py-0.5  */
  padding-top: 0.125rem; /* 2px */
  padding-bottom: 0.125rem; /* 2px */
  /* text-xs  */
  font-size: 0.75rem; /* 12px */
  line-height: 1rem; /* 16px */
  /* font-semibold  */
  font-weight: 600;
  /* transition-colors */
  transition-property: color, background-color, border-color,
    text-decoration-color, fill, stroke;
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  transition-duration: 150ms;
}

.badge:focus {
  /* focus:outline-none  */
  outline: 2px solid transparent;
  outline-offset: 2px;
  /* focus:ring-2  */
  box-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width))
    var(--tw-ring-color);
  /* focus:ring-ring  */
  --tw-ring-color: hsl(var(--ring));
  /* focus:ring-offset-2 */
  --tw-ring-offset-width: 2px;
  box-shadow:
    0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color),
    var(--tw-ring-shadow);
}

.badge.default {
  /* border-transparent  */
  border-color: transparent;
  /* bg-primary  */
  background-color: hsl(var(--primary));
  /* text-primary-foreground  */
  color: hsl(var(--primary-foreground));
  /* shadow  */
  box-shadow:
    0 1px 3px 0 rgb(0 0 0 / 0.1),
    0 1px 2px -1px rgb(0 0 0 / 0.1);
}

.badge.default:hover {
  /* hover:bg-primary/80 */
  background-color: hsl(var(--primary) / 0.8);
}

.badge.secondary {
  /* border-transparent  */
  border-color: transparent;
  /* bg-secondary  */
  background-color: hsl(var(--secondary));
  /* text-secondary-foreground  */
  color: hsl(var(--secondary-foreground));
}

.badge.secondary:hover {
  /* hover:bg-secondary/80 */
  background-color: hsl(var(--secondary) / 0.8);
}

.badge.destructive {
  /* border-transparent  */
  border-color: transparent;
  /* bg-destructive  */
  background-color: hsl(var(--destructive));
  /* text-destructive-foreground  */
  color: hsl(var(--destructive-foreground));
  /* shadow  */
  box-shadow:
    0 1px 3px 0 rgb(0 0 0 / 0.1),
    0 1px 2px -1px rgb(0 0 0 / 0.1);
}

.badge.destructive:hover {
  /* hover:bg-destructive/80 */
  background-color: hsl(var(--destructive) / 0.8);
}

.badge.outline {
  /* text-foreground */
  color: hsl(var(--foreground));
}

ユーティリティ関数cnの書き換え

shadcn/ui のユーティリティ関数cnの実装を github リポジトリから探してくると、以下の通りです。
(参照元)

utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

つまり、cnclsxtwMergeを組み合わせたものであることがわかります。

今回、Tailwind を剥がそうとしているのでtwmergeが不要になるため、cnclsxに書き換えれば良いことが分かります。

cnの書き換えは以下の通りになります。

Badge.tsx

import { clsx } from "clsx";

// 省略

function Badge({ className, variant, ...props }: BadgeProps) {
  console.log(badgeVariants());
  return (
    // before
    // <div className={cn(badgeVariants({ variant }), className)} {...props} />
    // after
    <div className={clsx(badgeVariants({ variant }), className)} {...props} />
  );
}

// 省略

以上でBadgeコンポーネントの CSS Module への書き換えは完了です!

余談:既に同じこと考えてる先人がいた、、、

同じこと考えている人いるのでは、と思い調べていたらドンピシャの Discussion が上がっていました。

Shadcn-ui variation without Tailwind · shadcn-ui/ui · Discussion #2832

この Discussion の中で、shadcn/ui 公式で CSS Module に対応するという情報はありませんが、有志の方が shadcn/ui の CSS Module 版をリポジトリとして公開しているようでした。(素晴らしい!!)

GitHub - qwalker8408/shadcn-css: Shadcn. No tailwindcss. CSS modules. next-themes.

ただし上記の実装を少し見てみると、

  • Tailwind + CVA で実装されている箇所を CVA ごと剥がして CSS Module に移植しているコンポーネントが一部ある
  • Next.js の App Router で実装されているので Vite など他フレームワークで使う場合は修正が必要
  • 現在のバージョンの shadcn/ui コンポーネントの CSS と若干異なる箇所がある(あえてそうしているのかバージョン追従できていなくてズレているのかは不明)

という点があるので、利用する場合はあくまで自分で書き換える気概を持ちながら活用していく、くらいのスタンスが良さそうです。

まとめ

この記事では、shadcn/ui の設計思想の説明と、Badgeコンポーネントの CSS Module への書き換えを行いました。

Tailwind で良くない?という方も多いとは思いますが、
例えば shadcn/ui が大きく依存している Radix UI Primitives では CSS は素の CSS, CSS Module, Tailwind の 3 つが選べるので、
shadcn/ui にも CSS Module 版があることの合理性は一定あるのでは?と思っています。

また、今回やってみて思いましたがいくら shadcn/ui の拡張性が高いといえども、使いたいコンポーネントを毎回 Tailwind から CSS Module に書き直すのはけっこうツライので公式から提供してくれる、もしくは別の OSS が提供されると嬉しいなと思いました。(なら自分でやれという声は聞こえない

一方 Tailwind を剥がしてしまうと、
Tailwind の強みである CSS ファイルが分離しない、デザインシステムに準拠したスタイルが書けるという利点も失われるので本当に CSS Module が最適かは状況ごとに要検討かと思います。

いずれにせよ、今後フロントエンド開発で Tailwind が CSS の覇権を握った場合は CSS Module にしたいとか誰も言わなくなると思うので、
その時は shadcn/ui 開発陣の先見の明がすごすぎる、ということになるでしょう。

Discussion