CSS Module版shadcn/uiがほしいという話
はじめに
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 ではコンポーネントの要素を
- 振る舞い(behaviors)
- 構造(structure)
- 視覚的なスタイル(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)
- headless UI Components (デザインを持たないコンポーネントライブラリ)で実装
- 具体的には Radix UI, React Hook Form, Tanstack Table など
- 視覚的なスタイル(visual style)
- Tailwind CSS と CVA (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点になります。
- Tailwind のユーティリティクラスの部分を CSS Module に書き換え
- shadcn/ui のユーティリティ関数
cn
を Tailwind 依存しないように書き換え
Badge
コンポーネントの書き換え前のコードは以下の通りです。
(参照元)
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 公式のこちらの実装サンプルを参考にしています。
// 省略
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 {
/* 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 リポジトリから探してくると、以下の通りです。
(参照元)
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
つまり、cn
はclsx
とtwMerge
を組み合わせたものであることがわかります。
今回、Tailwind を剥がそうとしているのでtwmerge
が不要になるため、cn
をclsx
に書き換えれば良いことが分かります。
cn
の書き換えは以下の通りになります。
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
chadcn → shadcn?
めちゃめちゃ誤字ってました、笑
ありがとうございます!