shadcn/uiでカスタムコンポーネントを作る — 実践テクニック集
shadcn/uiは「コンポーネントライブラリ」ではなく「コンポーネントのコレクション」です。コピー&ペーストで自分のプロジェクトに組み込み、完全にカスタマイズできる点が最大の特徴です。この記事では、shadcn/uiの仕組みを理解し、実践的なカスタムコンポーネントを作成する方法を解説します。
shadcn/uiの仕組み
shadcn/uiは以下の技術スタックで構成されています。
- Radix UI - アクセシブルなプリミティブコンポーネント
- Tailwind CSS - ユーティリティファーストなスタイリング
- class-variance-authority (CVA) - バリアント管理
- clsx / tailwind-merge - クラス名の結合と競合解決
コンポーネントは components/ui/ 配下に配置され、完全にあなたのコードになります。
// components/ui/button.tsx
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 transition-colors focus-visible:outline-none 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",
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
}
export 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"
カスタムバリアントの作成
既存のButtonコンポーネントに新しいバリアントを追加してみましょう。
const buttonVariants = cva(
// ... base classes
{
variants: {
variant: {
// 既存のバリアント...
gradient: "bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600",
glass: "bg-white/10 backdrop-blur-lg border border-white/20 text-white hover:bg-white/20",
animated: "bg-primary text-primary-foreground hover:scale-105 transition-transform",
},
size: {
// 既存のサイズ...
xs: "h-7 px-2 text-xs",
xl: "h-14 px-10 text-lg",
},
},
// 複数バリアントの組み合わせ
compoundVariants: [
{
variant: "gradient",
size: "lg",
className: "shadow-xl shadow-purple-500/50",
},
],
}
)
使用例:
<Button variant="gradient" size="lg">
グラデーションボタン
</Button>
<Button variant="glass">
グラスモーフィズム
</Button>
Radix UIプリミティブの拡張
shadcn/uiのコンポーネントはRadix UIのプリミティブをラップしています。カスタムコンポーネントを作成する際も同様のパターンを使用できます。
カスタムツールチップの作成
// components/ui/custom-tooltip.tsx
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const tooltipVariants = cva(
"z-50 overflow-hidden rounded-md px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
info: "bg-blue-500 text-white",
success: "bg-green-500 text-white",
warning: "bg-yellow-500 text-black",
error: "bg-red-500 text-white",
},
size: {
sm: "px-2 py-1 text-xs",
md: "px-3 py-1.5 text-sm",
lg: "px-4 py-2 text-base",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
}
)
interface CustomTooltipProps extends VariantProps<typeof tooltipVariants> {
content: React.ReactNode
children: React.ReactNode
side?: "top" | "right" | "bottom" | "left"
}
export function CustomTooltip({
content,
children,
variant,
size,
side = "top",
}: CustomTooltipProps) {
return (
<TooltipPrimitive.Provider>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>
{children}
</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
className={cn(tooltipVariants({ variant, size }))}
>
{content}
<TooltipPrimitive.Arrow className="fill-current" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
)
}
使用例:
<CustomTooltip content="これは成功メッセージです" variant="success">
<Button>ホバーしてください</Button>
</CustomTooltip>
テーマカスタマイズ
shadcn/uiはCSS変数でテーマを管理します。カスタムカラーパレットを作成しましょう。
/* globals.css */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* カスタムカラー */
--brand: 262 83% 58%;
--brand-foreground: 210 40% 98%;
/* グラデーション用 */
--gradient-start: 330 81% 60%;
--gradient-end: 262 83% 58%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--brand: 262 83% 58%;
--brand-foreground: 222.2 84% 4.9%;
}
}
Tailwind設定での利用:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
brand: {
DEFAULT: "hsl(var(--brand))",
foreground: "hsl(var(--brand-foreground))",
},
},
},
},
}
実用コンポーネント例
1. ステータスバッジ
// components/ui/status-badge.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const statusBadgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
status: {
success: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
warning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
info: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
neutral: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300",
},
size: {
sm: "px-2 py-0.5 text-xs",
md: "px-2.5 py-0.5 text-sm",
lg: "px-3 py-1 text-base",
},
withDot: {
true: "pl-1.5",
},
},
defaultVariants: {
status: "neutral",
size: "md",
withDot: false,
},
}
)
interface StatusBadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof statusBadgeVariants> {
children: React.ReactNode
}
export function StatusBadge({
className,
status,
size,
withDot,
children,
...props
}: StatusBadgeProps) {
return (
<span
className={cn(statusBadgeVariants({ status, size, withDot, className }))}
{...props}
>
{withDot && (
<span className="mr-1.5 h-1.5 w-1.5 rounded-full bg-current" />
)}
{children}
</span>
)
}
2. プログレスステッパー
// components/ui/stepper.tsx
import { cn } from "@/lib/utils"
interface Step {
label: string
description?: string
}
interface StepperProps {
steps: Step[]
currentStep: number
className?: string
}
export function Stepper({ steps, currentStep, className }: StepperProps) {
return (
<nav aria-label="Progress" className={cn("", className)}>
<ol className="flex items-center">
{steps.map((step, index) => {
const isComplete = index < currentStep
const isCurrent = index === currentStep
const isUpcoming = index > currentStep
return (
<li
key={step.label}
className={cn(
"relative flex-1",
index !== steps.length - 1 && "pr-8 sm:pr-20"
)}
>
<div className="flex items-center">
<div className="relative flex items-center justify-center">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-full border-2",
isComplete && "border-primary bg-primary",
isCurrent && "border-primary",
isUpcoming && "border-gray-300"
)}
>
{isComplete ? (
<svg
className="h-6 w-6 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
<span
className={cn(
"text-sm font-semibold",
isCurrent && "text-primary",
isUpcoming && "text-gray-500"
)}
>
{index + 1}
</span>
)}
</div>
</div>
{index !== steps.length - 1 && (
<div
className={cn(
"absolute left-10 right-0 top-5 -ml-px h-0.5",
isComplete ? "bg-primary" : "bg-gray-300"
)}
/>
)}
</div>
<div className="mt-2">
<p
className={cn(
"text-sm font-medium",
isCurrent && "text-primary",
isComplete && "text-foreground",
isUpcoming && "text-muted-foreground"
)}
>
{step.label}
</p>
{step.description && (
<p className="text-sm text-muted-foreground">
{step.description}
</p>
)}
</div>
</li>
)
})}
</ol>
</nav>
)
}
3. カスタムコマンドパレット
// components/ui/command-palette.tsx
import { useState } from "react"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
interface CommandAction {
id: string
label: string
icon?: React.ReactNode
keywords?: string[]
onSelect: () => void
}
interface CommandPaletteProps {
actions: CommandAction[]
open: boolean
onOpenChange: (open: boolean) => void
}
export function CommandPalette({
actions,
open,
onOpenChange,
}: CommandPaletteProps) {
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="コマンドを入力..." />
<CommandList>
<CommandEmpty>結果が見つかりません。</CommandEmpty>
<CommandGroup heading="アクション">
{actions.map((action) => (
<CommandItem
key={action.id}
keywords={action.keywords}
onSelect={() => {
action.onSelect()
onOpenChange(false)
}}
>
{action.icon && <span className="mr-2">{action.icon}</span>}
{action.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
)
}
// 使用例
export function App() {
const [open, setOpen] = useState(false)
const actions = [
{
id: "new-post",
label: "新しい投稿を作成",
keywords: ["create", "new", "post"],
onSelect: () => console.log("新規投稿"),
},
{
id: "settings",
label: "設定を開く",
keywords: ["settings", "preferences"],
onSelect: () => console.log("設定"),
},
]
return (
<>
<button onClick={() => setOpen(true)}>コマンドパレットを開く</button>
<CommandPalette actions={actions} open={open} onOpenChange={setOpen} />
</>
)
}
まとめ
shadcn/uiのカスタマイズは、以下のポイントを押さえることで自由自在に行えます。
- CVAでバリアント管理 - 型安全でメンテナンスしやすいバリアント定義
- Radix UIプリミティブの活用 - アクセシビリティが担保されたベース
- CSS変数でテーマ管理 - 柔軟なカラーシステム
- compoundVariantsで複雑な組み合わせ - 複数バリアントの相互作用
これらのテクニックを組み合わせることで、あなたのプロジェクトに最適化されたコンポーネントライブラリを構築できます。コードは完全にあなたのものなので、ビジネス要件に合わせて自由にカスタマイズしましょう。