発展編:他のコンポーネントへの応用
この章で学ぶこと
Button で学んだパターンを、他のコンポーネント(Badge・Input・Card・Alert)に応用します。同じパターンとデザイントークンを使い回すことで、コンポーネント間に一貫性が生まれます。
学んだパターンの振り返り
コアパターン
1. cva … バリアントごとのスタイル
2. cn … 安全なクラス結合(+ ...props で ref 透過)
3. Slot … 要素の差し替え(asChild)
4. デザイントークン … 色・余白の一貫した参照
どのコンポーネントも、この組み合わせで作れます。すべて React 19 のスタイル(forwardRef なし)です。
応用例1: Badge(cva + cn)
ステータス表示などに使う Badge です。バリアントがあるので cva、色はトークンを使います。
// src/components/ui/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-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-white",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
}
)
function Badge({
className,
variant,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return <span data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
<Badge>Default</Badge>
<Badge variant="secondary">下書き</Badge>
<Badge variant="destructive">エラー</Badge>
<Badge variant="outline">枠線</Badge>
余白や配置などレイアウトの微調整は className で行えます(cn が安全にマージします)。
{/* 通知数バッジ:見出しの隣に余白を空けて置く */}
<h2>
お知らせ<Badge variant="destructive" className="ml-2">3</Badge>
</h2>
{/* アイコンやテキストと並べるときの上余白 */}
<Badge variant="secondary" className="mt-1">下書き</Badge>
アイコンを添えると、ステータスがひと目で伝わります。本書の Badge はベースに gap を持たないので、アイコンとテキストの間隔は gap-1 を className で足します(これもレイアウト調整なので OK です)。
import { Check, Clock } from "lucide-react"
<Badge className="gap-1">
<Check className="size-3" />
完了
</Badge>
<Badge variant="secondary" className="gap-1">
<Clock className="size-3" />
進行中
</Badge>
<Badge variant="destructive" className="gap-1">
<Clock className="size-3" />
期限切れ
</Badge>
Badge は asChild が不要なシンプルなケースです。必要になったら Button と同じように追加できます。
頻繁にアイコン付きで使うなら、毎回 className="gap-1" を書く代わりに、badgeVariants のベースクラスに gap-1 を加えてしまう手もあります。「よく使う組み合わせはバリアント側に寄せる」と、利用側がすっきりします。
応用例2: Input(cn のみ)
入力欄です。見た目のバリエーションは少ないので cva は使わず、cn だけで作ります。
ポイントは ref を ...props で透過することです。入力欄は React Hook Form などのフォームライブラリと組み合わせて使うことが多く、それらは ref を通じて入力欄の DOM を直接操作します。たとえば「バリデーションエラーの入力欄に自動でフォーカスを当てる」「入力値を読み取る」といった処理です。Input が ref を透過していないと、こうしたライブラリと連携できません。React 19 では ref も ...props に含まれるので(ref の章)、特別な記述なしにこれが実現できます。
// src/components/ui/input.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Input }
<Input placeholder="お名前" />
<Input type="email" placeholder="メールアドレス" />
<Input disabled placeholder="無効" />
応用例3: Card(複合コンポーネント)
Card は、複数の小さなコンポーネントを組み合わせて使う「複合コンポーネント」パターンです。それぞれ同じ形(ComponentProps + cn + data-slot)で作ります。
data-slot はここで活きます。親要素から [&_[data-slot=card-header]]:border-b のように子を狙ってスタイルを当てられるので、複合コンポーネントの一括スタイリングに便利です。
// src/components/ui/card.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-header" className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("font-semibold leading-none", className)} {...props} />
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
}
// CardDescription / CardFooter も同じ形で作れます
export { Card, CardHeader, CardTitle, CardContent }
<Card>
<CardHeader>
<CardTitle>カードのタイトル</CardTitle>
</CardHeader>
<CardContent>
<p>カードの中身です。</p>
<Button>アクション</Button>
</CardContent>
</Card>
応用例4: Alert(cva + cn + asChild)
Button と同じ 4 点セットを使う例です。asChild で <section> などのセマンティックな要素にもできます。
// src/components/ui/alert.tsx
import * as React from "react"
import { Slot } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva("relative w-full rounded-lg border p-4", {
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive: "border-destructive/50 text-destructive",
},
},
defaultVariants: { variant: "default" },
})
function Alert({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
export { Alert, alertVariants }
<Alert variant="destructive">
<strong>エラー:</strong> 問題が発生しました。
</Alert>
<Alert asChild>
<section aria-label="成功メッセージ">
<strong>成功:</strong> 変更を保存しました。
</section>
</Alert>
パターンの選択ガイド
Q. スタイルのバリエーションがある? → cva を使う
Q. className でカスタマイズさせたい? → cn を使う(+ ...props)
Q. レンダリングする要素を差し替えたい? → Slot(asChild)を使う
※ ref は React 19 では ...props に含まれて透過するので、特別な対応(forwardRef)は不要。
推奨の組み合わせ
| コンポーネント | cva | cn | asChild |
|---|---|---|---|
| Button | ✅ | ✅ | ✅ |
| Badge | ✅ | ✅ | - |
| Input | - | ✅ | - |
| Card | - | ✅ | - |
| Alert | ✅ | ✅ | ✅ |
どのコンポーネントも cn と ...props は共通です。cva はバリアントがあるとき、asChild は要素を差し替えたいときに足します。
コンポーネント設計のベストプラクティス
1. 単一責任
各コンポーネントは 1 つの責務に絞り、組み合わせて使います。
// ✅ 責務を分けて合成する
<Tooltip content="送信する">
<Button>
<CheckIcon />
送信
</Button>
</Tooltip>
2. デフォルト値を用意する
defaultVariants で、props なしでも使える状態にします。
3. HTML 属性は ComponentProps で取り込む
// それぞれの要素に合った ComponentProps を使う
React.ComponentProps<"button"> // ボタン
React.ComponentProps<"input"> // 入力欄
React.ComponentProps<"div"> // 汎用コンテナ
4. デザイントークンで色を指定する
bg-blue-500 ではなく bg-primary / bg-destructive などのトークンを使うことで、全コンポーネントの色とテーマが揃います。これが一貫性の要です。
本物の Button との差分
完成形の章で予告していた「本物の Button」を見ておきましょう。現行の shadcn/ui registry の Button は、本書で作った Button と同じ構造(cva + cn + Slot + トークン)のまま、主に次の 3 点が拡張されています。
- size が 8 種:本書の
default/sm/lg/iconに加えて、xs/icon-xs/icon-sm/icon-lgが定義されています。 data-variant/data-size属性:data-slot="button"に加えて、適用中のバリアントを示すdata-variantとdata-sizeも出力します。CSS やテストから「variant / size 単位」でボタンを狙えます。- アイコン自動調整などのクラス:子の SVG アイコンのサイズ・余白を自動調整する
[&_svg]系のクラスや、aria-invalid時の見た目に対応するクラスなど、さらに多くのユーティリティを持ちます。
いずれも「クラスとバリアントが増えた」だけで、読み方は本書の Button と変わりません。
shadcn/ui の他のコンポーネントを読む
本書の知識で、shadcn/ui の他のコンポーネントも読めるようになります。
シンプル: Badge, Input, Label → cva + cn の基本
中級: Card, Alert, Avatar → 複合コンポーネント
上級: Dialog, DropdownMenu → Radix UI との連携
エキスパート: Form, DataTable → 外部ライブラリとの統合
参考リンク
この章のまとめ
- 学んだパターン(cva / cn / Slot / トークン)はあらゆるコンポーネントに応用できる
- どれも
cn+...propsが共通の土台 cvaはバリアント、asChildは要素差し替えのときに足す- 共通のデザイントークンを使うことで、コンポーネント間に一貫性が生まれる
次章では、ここまで作ってきた個々のコンポーネントを、1 つの「デザインシステム」としてまとめあげる視点を学びます。