これはなに
最近、社内プロジェクトで新進気鋭のshadcn/uiを用いた開発を経験しました。
この記事ではshadcn/uiの特徴や使ってみての感想、shadcn/uiが与える示唆についてご紹介します。
shadcn/uiの特徴
shadcn/uiは、Radix UIとTailwind CSSを組み合わせたコンポーネントのカタログです。その特徴をひとつずつ見ていきましょう!
コンポーネントライブラリではない
最も注目するべき特徴は、shadcn/uiはMUIやChakra UIとは異なり、コンポーネントライブラリではないということです。これは公式ドキュメントでも明記されています。
This is NOT a component library. It's a collection of re-usable components that you can copy and paste into your apps. ( Introduction より引用)
利用者は上述の通り”copy and paste”をするか、または専用のCLIを通じて手元のプロジェクトに引っ張ってきてコンポーネントを利用します。
例えば、Checkboxコンポーネントを利用したいとしましょう。
コピペする場合
Manual Installationに従い、依存ライブラリのインストールとコンポーネント定義のコピペを行うことでコンポーネントが利用できるようになります。
CLIを利用する場合
下記のコマンドを叩くことで規定の場所にファイルが作成され、コンポーネントが利用できるようになります。
npx shadcn-ui@latest add checkbox
CLIの設定は下記のような components.json
で行い、設定自体もinitコマンドから行うことができます。
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "styles/tailwind.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
CSS変数によるTheme管理
shadcn/uiはTheme管理として、「CSS変数でHSLを管理してその変数をTailwind側に組み込む」という仕組みを採用しています。
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}
}
// tailwind.config.js
theme:{
...
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
...
これにより、 bg-background
や text-secondary
といった記述を保ったまま、CSS変数の値をカスタムすることで全体のThemeを調整することができます。
また同時に、dark modeのときにCSS変数の値を切り替えることで、半自動的にdark modeに対応できるようになっています。
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--ring: 217.2 32.6% 17.5%;
}
一般にTailwind CSSでのdark mode対応は text-slate-700 dark:text-slate-200
のような記述をしなければいけませんが、この工夫によって例えば text-primary
という記述だけで両テーマに対応したスタイリングができていることになります。
開発中の記述が楽な他、Themeの設定ががほとんどCSSファイルに集約されるのも嬉しいポイントです。最近ではTailwindから設定ファイルをより簡潔にする提案もなされているので、もっと把握が容易なかたちになるかもしれません。
CVAと自前のutilによる明快なスタイル記述
shadcn/uiはCVAというライブラリを利用して、複数のvariantがあるようなコンポーネントに対して構造的なスタイリングの記述を行っています。
例えばButtonなどが顕著なのですが、これが本当に見やすいです。「ベーススタイリング+variantごとのスタイリング」という構造が一発でわかります。
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
VariantProps<typeof buttonVariants>
を利用してvariantsをコンポーネントのinterfaceに組み込むことができ、利用の仕方も明快です。
<Button variant="outline" size='lg'>Outline</Button>
また、ベースとしてclsxとtailwind-mergeを組み合わせたutilが用意されています。
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
こちらは追加classNameの合成で使われている他、真偽値に応じたスタイル変化にも便利です。
className={cn("-mx-1 my-1 h-px bg-muted", isSelected && 'font-bold', className)}
Tailwind CSSの使いづらさとしてclassNameの肥大化がしばしば指摘されますが、 cn
のような関数を使うことで意味のまとまりごとに整理してclassNameを記述することもできます。
<X
className={cn(
'hidden group-hover:block',
'absolute -right-2 -top-2 z-10',
'h-5 w-5 rounded-full border border-muted bg-muted-foreground p-0.5 text-muted'
)}
/>
CVAとTailwindの組み合わせが広くユーザーに受け入れられてか、tailwind-mergeを内包した「Tailwind特化版CVA」のようなライブラリも出てきています。
こちらはCVAの基本的なインターフェイスを継承しつつ、CVAにはないSlotsの機能を持っているので、上手く使えばさらに書き味が良くなるかもしれません。
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",
},
});
const { base, avatar, wrapper, description, infoWrapper, name, role } = card();
使いたくなるようなコンポーネント
shadcn/uiは”a collection of re-usable components”という性質上、ButtonやCheckboxといったプリミティブなコンポーネントだけでなく、CommandやData Tableといった「派手な」コンポーネントも用意されています。
これらの「派手な」コンポーネントのベースはしばしばRadix UIでは用意されていません。しかし、Radix UI以外のコンポーネントライブラリも柔軟に取り入れながらカタログを充実させていくのがshadcnのスタイルであるようです。Commandにはcmdk、Data TableにはTanstack Tableが利用されています。
最近は複雑なコンポーネントの充実に力を入れている印象で、Twitterでもよく楽しそうな実装報告を見かけます。
多様なフレームワークに対応
もともとは”App Directory対応”と銘打っていたshadcn/uiでしたが、あまりの人気ぶりにか、最近は多様なフレームワークに対応しています。もちろんNext.js App Routerに対応している他、RemixやAstroでのsetup方法も紹介されています。
使ってみて
実際に使ってみて感じたメリット・デメリットをお伝えします。
「定義からいじれる」という最強の柔軟性
ここが最大の恩恵に感じました。一般的なコンポーネントライブラリを使用すると、ライブラリが提供しているAPIを通じてのみしかコンポーネントをカスタムすることができません。しかしコンポーネントの定義が手元にあるshadcnは根っこからカスタムすることができます。
「ここの利用箇所だけpaddingを大きく!」といった局所的なカスタムは、コンポーネントに渡す className
props経由で気軽に行うことができますし、
<Button className='px-10'>
「全体的に角丸強めたいんだよね〜」といった包括的なカスタムは、CSS変数やコンポーネント定義時のスタイルの変更によって対応できます。
--radius: 0.8rem;
^^^^^^
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-xl border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className ^^^^^^^^^^
)}
- 利用序盤は最初から用意されているものに乗っかることができて楽
- 中盤からはカスタムの幅が広くて気が楽
という二重の良さがあります。
アプデは激しいけれど、追従はマイペースにできる
特に最近は立て続けに大型アップデートがあったshadcn/uiですが、ライブラリではないので「アプデに追従しない」という判断も比較的気楽に取ることができます。また、アップデートは一括で行う必要がなく、「目についたコンポーネントから少しずつやっていく」というスタイルを取ることができます。
Storybookも同梱してほしい
shadcnはこのようなフォルダ構成を想定しているのですが、
├── components
│ ├── ui
│ │ ├── alert-dialog.tsx
│ │ ├── button.tsx
│ │ ├── dropdown-menu.tsx
実際にはこのようにStorybookを同梱するようなフォルダ構成で利用することが多いかと思います。
├── components
│ ├── ui
│ │ ├── alert-dialog
│ │ │ │ ├── index.tsx
│ │ │ │ └── index.stories.tsx
│ │ ├── button
│ │ │ │ ├── index.tsx
│ │ │ │ └── index.stories.tsx
私が経験した社内プロジェクトでもわざわざ下記のような運用でStorybook込みのフォルダを用意していました。
- scaffdogでコンポーネントフォルダのテンプレートを容易
- scaffdogで生成したファイルにshadcn/uiをコピペ
- Storybookをちまちま用意
世界中の利用者が自前でStorybookを用意するのもなんなので、shadcnが公式でStorybookを付けてほしいなと思っています。
実際、この提案はだいぶ前から議論されており、もうほとんど「待ったなし」といったところまで来ています。とても楽しみです。
カスタムも楽しい
もともと「コピペ可能なテンプレート」として想定されているshadcn/uiは、「使い込むならカスタム前提」という側面があります。GitHub Pull Requestsなどでも、ユーザーが自前のカスタムを紹介しているのをちらほら見かけます。
中には結構ガッツリとしたカスタムを紹介しているユーザーもいて、例えばmxkaskeは有名です。
自分も簡単なものですがちょくちょくカスタムを書いています。なんだかんだ、育てていくのは楽しいですよね。
利用者はお互いにカスタムを紹介して利用しあっています。WebフレームワークではしばしばShowcaseという形でユーザーによる使用例が紹介されますが、shadcn/uiでも同じようにユーザーによるアイデアを共有するような場が求められているかもしれません。
ライブラリでないがゆえに少し戸惑う
「アプデは激しいけれど、追従はマイペースにできる」をメリットとして上げましたが、やはり本体のアプデにどこまで追従するかは悩みます。特に、定義に直接カスタムを書き込んでいると、どこがカスタムされているかわかりづらいため、すんなり「最新版をコピペで上書き!」というわけには行きません。
また、「 @/components/ui/
ディレクトリにshadcn/uiとそうでないものが混在する」という状況もしばしば発生します。例えば私の同僚はRadix UIのToolbarを利用してshadcn/uiにはない(しかしそれっぽい)Toolbarコンポーネントを作成していました。「これはshadcnから来ているけれど、これはそうではない」という区別が必要なときはつらいかもしれません。
私が参加したプロジェクトでは、このようにファイルの冒頭にコメントを入れることで対応していましたが、あまり本質的な解決策ではないなと思っています。
/**
* ref: https://ui.shadcn.com/docs/components/skeleton
*
* custom:
* - asChildを追加
*/
import { cn } from '@/lib/utils'
import { Slot } from '@/components/ui/Slot'
export const Skeleton = ({
asChild,
className,
...props
}: { asChild?: boolean } & React.HTMLAttributes<HTMLDivElement>) => {
const Comp = asChild ? Slot : 'div'
return (
<Comp
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
)
}
かといって、shadcn/uiを特別扱いして @/components/ui/shadcn/
とかして隔離しまうと、普通のライブラリと変わらなくなってメリットが薄れるので難しいです。
現状ではまだexperimentalですが、shadcnはCLIでdiffを比較するような方針を見ているようです。
shadcn/ui的なあり方に学ぶ、UIコンポーネントとの付き合い方
私がshadcn/uiに触れてみて最も勉強になったのは、UIコンポーネントとの付き合い方です。
一般に、UIコンポーネントの開発のときはこのような悩みがつきまといます。
- UIは限られたパターンの組み合わせであり、定番UIの再開発はしたくない
- さらにTabなどの複雑なコンポーネントは特にa11y対応に気を使って面倒
- 表層の部分も「ある程度いい感じ」になってたら最初はそれでいいことが多い
というわけでMUIやChakra UIなどのコンポーネントライブラリを選択するわけですが、今度はこういうわがままを言いたくなります。
- スタイリングはいつでもカスタムできるようにしておきたい
- ライブラリにがっつりインターフェイスを握られたくはない
shadcn/uiはこの問題に対して素晴らしい回答をしてくれました。つまり、下記のようなUIコンポーネントとの付き合い方の提案です。
- ヘッドレスコンポーネントライブラリを用いて、余計な再開発はしない
- 基本的なCSSは予め書いて、どのプロジェクトでも使えるようにしておく
- 用途に応じて利用者側でカスタムする
shadcn/uiはたまたまRadix UIとTailwind CSSの組み合わせですが、ヘッドレスコンポーネントライブラリとCSSライブラリは好きなのを選べば良いのだろうと思います。最近ではReact Area ComponentsとTailwind CSSの組み合わせであるCatalystが発表されました。
CSSライブラリの開発も最近盛んですし、これからたくさんの組み合わせが提案されていくと思います。
デザインシステムの構築も、OSSに上手く乗っかるshadcn/ui的なあり方が好まれるようになるのではないでしょうか。
また、開発者TwitterではAIを用いたコンポーネントのカスタムが注目を集めています。
UIの「表層」を作り込む作業はなるべくデザインシステムやAIに預けて、開発者はより高次な「構造」や「要件」の部分に集中することが期待できますね。