Next.jsアプリケーションでのshadcn/ui活用術

Next.jsアプリケーションでのshadcn/ui活用術

公開: 2025年7月6日
更新: 2025年7月6日
12分で読めます

はじめに

現代のWebアプリケーション開発において、美しく一貫性のあるUIを効率的に構築することは必須要件となっています。特にNext.jsのようなフルスタックフレームワークでは、開発速度と品質を両立できるUIライブラリの選択が重要です。

今回は、2023年から急速に注目を集めているshadcn/uiを、Next.jsアプリケーションで最大限活用するためのベストプラクティスをご紹介します。

shadcn/uiとは?なぜ革新的なのか

shadcn/uiは、従来のUIライブラリとは異なるアプローチを採用したReactコンポーネントコレクションです。その特徴的な点を見てみましょう:

従来のライブラリとの違い

  • NPMパッケージではない: コンポーネントを直接プロジェクトにコピー
  • 完全なカスタマイズ性: ソースコードを直接編集可能
  • Tailwind CSS + Radix UIベース: 現代的なスタックを採用
  • TypeScript完全対応: 型安全性を保ちながら開発

Next.jsとの相性が抜群な理由

// 自動的にサーバーコンポーネントとクライアントコンポーネントを適切に処理 import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" // SSRでも完璧に動作 export default function HomePage() { return ( <Card> <CardHeader> <CardTitle>サーバーサイドで完全レンダリング</CardTitle> </CardHeader> <CardContent> <Button>クライアントインタラクション対応</Button> </CardContent> </Card> ) }

プロジェクトセットアップ:完璧な開発環境を構築

1. 新規プロジェクトでの最適なセットアップ

# Next.jsプロジェクトの作成(推奨設定) npx create-next-app@latest my-app --typescript --tailwind --eslint --app # プロジェクトディレクトリに移動 cd my-app # shadcn/uiの初期化 npx shadcn-ui@latest init

初期化時の推奨設定:

// components.json(自動生成される設定ファイル) { "style": "default", "rsc": true, // React Server Components対応 "tsx": true, // TypeScript使用 "tailwind": { "config": "tailwind.config.js", "css": "app/globals.css", "baseColor": "slate", // プロジェクトに応じて選択 "cssVariables": true // CSS変数を使用(推奨) }, "aliases": { "components": "@/components", "utils": "@/lib/utils" } }

2. 既存プロジェクトへの段階的導入

# 必要な依存関係のインストール npm install @radix-ui/react-slot class-variance-authority clsx tailwind-merge lucide-react # 個別コンポーネントの追加 npx shadcn-ui@latest add button npx shadcn-ui@latest add card npx shadcn-ui@latest add input

ベストプラクティス1: コンポーネント設計と組織化

層別アーキテクチャの採用

// components/ui/ - shadcn/uiの基本コンポーネント // components/common/ - プロジェクト共通コンポーネント // components/features/ - 機能別コンポーネント // components/layouts/ - レイアウトコンポーネント // components/common/data-table.tsx import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" interface DataTableProps<T> { data: T[] columns: ColumnDef<T>[] searchable?: boolean pagination?: boolean } export function DataTable<T>({ data, columns, searchable = true }: DataTableProps<T>) { // shadcn/uiコンポーネントを組み合わせた複合コンポーネント return ( <div className="space-y-4"> {searchable && ( <Input placeholder="検索..." className="max-w-sm" /> )} <Table> <TableHeader> {/* テーブルヘッダーの実装 */} </TableHeader> <TableBody> {/* テーブルボディの実装 */} </TableBody> </Table> </div> ) }

コンポーネントのバリエーション管理

// 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 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", { 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 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: "underline-offset-4 hover:underline text-primary", // カスタムバリエーションの追加 gradient: "bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:from-blue-700 hover:to-purple-700", loading: "bg-primary/80 text-primary-foreground cursor-not-allowed", }, size: { default: "h-10 py-2 px-4", sm: "h-9 px-3 rounded-md", lg: "h-11 px-8 rounded-md", icon: "h-10 w-10", // カスタムサイズの追加 xl: "h-12 px-10 text-base", }, }, defaultVariants: { variant: "default", size: "default", }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean loading?: boolean } export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, asChild = false, loading, children, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( <Comp className={cn(buttonVariants({ variant: loading ? "loading" : variant, size, className }))} ref={ref} disabled={loading || props.disabled} {...props} > {loading ? ( <> <svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <path className="opacity-75" fill="currentColor" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> 読み込み中... </> ) : children} </Comp> ) } )

ベストプラクティス2: テーマシステムとデザイントークン

CSS変数を活用したテーマ管理

/* app/globals.css */ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { /* ライトテーマ */ --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 221.2 83.2% 53.3%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96%; --secondary-foreground: 222.2 84% 4.9%; --muted: 210 40% 96%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96%; --accent-foreground: 222.2 84% 4.9%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 221.2 83.2% 53.3%; --radius: 0.5rem; /* カスタムブランドカラー */ --brand-primary: 210 100% 50%; --brand-secondary: 340 82% 52%; --success: 142 76% 36%; --warning: 38 92% 50%; --info: 199 89% 48%; } .dark { /* ダークテーマ */ --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 217.2 91.2% 59.8%; --primary-foreground: 222.2 84% 4.9%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 224.3 76.3% 94.1%; /* ダークテーマ用ブランドカラー */ --brand-primary: 210 100% 60%; --brand-secondary: 340 82% 62%; } } /* カスタムユーティリティクラス */ @layer utilities { .text-brand-primary { color: hsl(var(--brand-primary)); } .bg-brand-primary { background-color: hsl(var(--brand-primary)); } .border-brand-primary { border-color: hsl(var(--brand-primary)); } }

テーマプロバイダーの実装

// components/theme-provider.tsx "use client" import * as React from "react" import { ThemeProvider as NextThemesProvider } from "next-themes" import { type ThemeProviderProps } from "next-themes/dist/types" export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return <NextThemesProvider {...props}>{children}</NextThemesProvider> } // app/layout.tsx import { ThemeProvider } from "@/components/theme-provider" export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ja" suppressHydrationWarning> <body> <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} </ThemeProvider> </body> </html> ) } // components/mode-toggle.tsx "use client" import * as React from "react" import { Moon, Sun } from "lucide-react" import { useTheme } from "next-themes" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" export function ModeToggle() { const { setTheme } = useTheme() return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="icon"> <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <span className="sr-only">テーマを切り替え</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => setTheme("light")}> ライト </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme("dark")}> ダーク </DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme("system")}> システム </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) }

ベストプラクティス3: フォームとバリデーション

React Hook FormとZodの統合

// lib/validations/auth.ts import * as z from "zod" export const loginSchema = z.object({ email: z .string() .min(1, "メールアドレスは必須です") .email("有効なメールアドレスを入力してください"), password: z .string() .min(8, "パスワードは8文字以上である必要があります") .regex( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, "パスワードには大文字、小文字、数字が含まれている必要があります" ), }) export type LoginFormValues = z.infer<typeof loginSchema> // components/forms/login-form.tsx "use client" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { Button } from "@/components/ui/button" import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { loginSchema, type LoginFormValues } from "@/lib/validations/auth" import { useState } from "react" import { Eye, EyeOff } from "lucide-react" export function LoginForm() { const [showPassword, setShowPassword] = useState(false) const [isLoading, setIsLoading] = useState(false) const form = useForm<LoginFormValues>({ resolver: zodResolver(loginSchema), defaultValues: { email: "", password: "", }, }) async function onSubmit(values: LoginFormValues) { setIsLoading(true) try { // ログイン処理 console.log(values) // API呼び出し等 } catch (error) { console.error("ログインエラー:", error) } finally { setIsLoading(false) } } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>メールアドレス</FormLabel> <FormControl> <Input type="email" placeholder="[email protected]" {...field} /> </FormControl> <FormDescription> ログインに使用するメールアドレスを入力してください </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="password" render={({ field }) => ( <FormItem> <FormLabel>パスワード</FormLabel> <FormControl> <div className="relative"> <Input type={showPassword ? "text" : "password"} placeholder="パスワードを入力" {...field} /> <Button type="button" variant="ghost" size="icon" className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" onClick={() => setShowPassword(!showPassword)} > {showPassword ? ( <EyeOff className="h-4 w-4" /> ) : ( <Eye className="h-4 w-4" /> )} <span className="sr-only"> {showPassword ? "パスワードを隠す" : "パスワードを表示"} </span> </Button> </div> </FormControl> <FormMessage /> </FormItem> )} /> <Button type="submit" className="w-full" loading={isLoading}> ログイン </Button> </form> </Form> ) }

ベストプラクティス4: 高度なコンポーネントパターン

複合コンポーネントパターンの実装

// components/ui/data-display.tsx import * as React from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" // 複合コンポーネントのコンテキスト interface DataDisplayContextValue { variant?: "default" | "compact" | "detailed" size?: "sm" | "md" | "lg" } const DataDisplayContext = React.createContext<DataDisplayContextValue>({}) // メインコンポーネント interface DataDisplayProps extends React.HTMLAttributes<HTMLDivElement> { variant?: "default" | "compact" | "detailed" size?: "sm" | "md" | "lg" } const DataDisplay = React.forwardRef<HTMLDivElement, DataDisplayProps>( ({ className, variant = "default", size = "md", ...props }, ref) => { const value = React.useMemo( () => ({ variant, size }), [variant, size] ) return ( <DataDisplayContext.Provider value={value}> <div ref={ref} className={cn( "space-y-4", { "space-y-2": size === "sm", "space-y-4": size === "md", "space-y-6": size === "lg", }, className )} {...props} /> </DataDisplayContext.Provider> ) } ) // サブコンポーネント const DataDisplayHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const { variant, size } = React.useContext(DataDisplayContext) return ( <div ref={ref} className={cn( "flex items-center justify-between", { "pb-2": variant === "compact", "pb-4": variant === "default", "pb-6": variant === "detailed", }, className )} {...props} /> ) }) const DataDisplayTitle = React.forwardRef< HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement> >(({ className, ...props }, ref) => { const { size } = React.useContext(DataDisplayContext) return ( <h3 ref={ref} className={cn( "font-semibold leading-none tracking-tight", { "text-sm": size === "sm", "text-lg": size === "md", "text-xl": size === "lg", }, className )} {...props} /> ) }) const DataDisplayContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const { variant } = React.useContext(DataDisplayContext) return ( <div ref={ref} className={cn( { "space-y-2": variant === "compact", "space-y-4": variant === "default", "space-y-6": variant === "detailed", }, className )} {...props} /> ) }) const DataDisplayActions = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const { size } = React.useContext(DataDisplayContext) return ( <div ref={ref} className={cn( "flex items-center gap-2", { "gap-1": size === "sm", "gap-2": size === "md", "gap-3": size === "lg", }, className )} {...props} /> ) }) // 使用例 export function UserProfileCard({ user }: { user: User }) { return ( <Card> <CardHeader> <DataDisplay variant="detailed" size="lg"> <DataDisplayHeader> <DataDisplayTitle>{user.name}</DataDisplayTitle> <DataDisplayActions> <Badge variant={user.isActive ? "default" : "secondary"}> {user.isActive ? "アクティブ" : "非アクティブ"} </Badge> <Button variant="outline" size="sm"> 編集 </Button> </DataDisplayActions> </DataDisplayHeader> <DataDisplayContent> <p className="text-muted-foreground">{user.email}</p> <p className="text-sm"> 最終ログイン: {user.lastLoginAt?.toLocaleDateString("ja-JP")} </p> </DataDisplayContent> </DataDisplay> </CardHeader> </Card> ) } export { DataDisplay, DataDisplayHeader, DataDisplayTitle, DataDisplayContent, DataDisplayActions, }

ベストプラクティス5: パフォーマンス最適化

動的インポートとコード分割

// components/ui/chart.tsx import dynamic from 'next/dynamic' import { Skeleton } from "@/components/ui/skeleton" // 重いチャートコンポーネントを動的にロード const Chart = dynamic( () => import('recharts').then((mod) => mod.LineChart), { loading: () => ( <div className="space-y-2"> <Skeleton className="h-4 w-full" /> <Skeleton className="h-4 w-3/4" /> <Skeleton className="h-64 w-full" /> </div> ), ssr: false, // クライアントサイドでのみレンダリング } ) // ダイアログコンポーネントの遅延読み込み const LazyDialog = dynamic( () => import('./advanced-dialog').then((mod) => mod.AdvancedDialog), { loading: () => <Skeleton className="h-96 w-full" />, } ) export function DataVisualization({ data }: { data: ChartData[] }) { const [showAdvanced, setShowAdvanced] = useState(false) return ( <div className="space-y-4"> <Chart data={data} /> <Button onClick={() => setShowAdvanced(true)} variant="outline" > 詳細設定を開く </Button> {showAdvanced && ( <LazyDialog open={showAdvanced} onOpenChange={setShowAdvanced} /> )} </div> ) }

メモ化とパフォーマンス最適化

// components/optimized-list.tsx import { memo, useMemo, useCallback } from 'react' import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" interface ListItemProps { item: ListItem onEdit: (id: string) => void onDelete: (id: string) => void } // 個別アイテムコンポーネントをメモ化 const ListItem = memo<ListItemProps>(({ item, onEdit, onDelete }) => { const handleEdit = useCallback(() => { onEdit(item.id) }, [item.id, onEdit]) const handleDelete = useCallback(() => { onDelete(item.id) }, [item.id, onDelete]) return ( <Card> <CardContent className="flex items-center justify-between p-4"> <div> <h3 className="font-semibold">{item.title}</h3> <p className="text-sm text-muted-foreground">{item.description}</p> </div> <div className="flex gap-2"> <Button variant="outline" size="sm" onClick={handleEdit}> 編集 </Button> <Button variant="destructive" size="sm" onClick={handleDelete}> 削除 </Button> </div> </CardContent> </Card> ) }) interface OptimizedListProps { items: ListItem[] onEdit: (id: string) => void onDelete: (id: string) => void searchQuery: string } export function OptimizedList({ items, onEdit, onDelete, searchQuery }: OptimizedListProps) { // フィルタリングロジックをメモ化 const filteredItems = useMemo(() => { if (!searchQuery) return items return items.filter(item => item.title.toLowerCase().includes(searchQuery.toLowerCase()) || item.description.toLowerCase().includes(searchQuery.toLowerCase()) ) }, [items, searchQuery]) // コールバック関数をメモ化 const handleEdit = useCallback((id: string) => { onEdit(id) }, [onEdit]) const handleDelete = useCallback((id: string) => { onDelete(id) }, [onDelete]) return ( <div className="space-y-4"> {filteredItems.map((item) => ( <ListItem key={item.id} item={item} onEdit={handleEdit} onDelete={handleDelete} /> ))} </div> ) }

実践例:ダッシュボードページの構築

shadcn/uiを使った実際のダッシュボードページを構築してみましょう:

// app/dashboard/page.tsx import { Suspense } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Overview } from "@/components/dashboard/overview" import { RecentSales } from "@/components/dashboard/recent-sales" import { Search } from "@/components/dashboard/search" import { UserNav } from "@/components/dashboard/user-nav" import { CalendarDateRangePicker } from "@/components/dashboard/date-range-picker" import { Button } from "@/components/ui/button" import { Download, Plus } from "lucide-react" import { Skeleton } from "@/components/ui/skeleton" // サーバーコンポーネントとして実装 export default async function DashboardPage() { // サーバーサイドでデータを取得 const [salesData, analyticsData] = await Promise.all([ getSalesData(), getAnalyticsData(), ]) return ( <div className="flex-col md:flex"> <div className="border-b"> <div className="flex h-16 items-center px-4"> <div className="ml-auto flex items-center space-x-4"> <Search /> <UserNav /> </div> </div> </div> <div className="flex-1 space-y-4 p-8 pt-6"> <div className="flex items-center justify-between space-y-2"> <h2 className="text-3xl font-bold tracking-tight">ダッシュボード</h2> <div className="flex items-center space-x-2"> <CalendarDateRangePicker /> <Button> <Download className="mr-2 h-4 w-4" /> エクスポート </Button> </div> </div> <Tabs defaultValue="overview" className="space-y-4"> <TabsList> <TabsTrigger value="overview">概要</TabsTrigger> <TabsTrigger value="analytics">分析</TabsTrigger> <TabsTrigger value="reports">レポート</TabsTrigger> <TabsTrigger value="notifications">通知</TabsTrigger> </TabsList> <TabsContent value="overview" className="space-y-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <Card> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardTitle className="text-sm font-medium"> 総売上 </CardTitle> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" className="h-4 w-4 text-muted-foreground" > <path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /> </svg> </CardHeader> <CardContent> <div className="text-2xl font-bold">¥{salesData.total.toLocaleString()}</div> <p className="text-xs text-muted-foreground"> 前月比 +20.1% </p> </CardContent> </Card> <Card> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardTitle className="text-sm font-medium"> アクティブユーザー </CardTitle> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" className="h-4 w-4 text-muted-foreground" > <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> <circle cx="9" cy="7" r="4" /> <path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" /> </svg> </CardHeader> <CardContent> <div className="text-2xl font-bold">+{analyticsData.activeUsers.toLocaleString()}</div> <p className="text-xs text-muted-foreground"> 前月比 +180.1% </p> </CardContent> </Card> {/* 他のメトリクスカード */} </div> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> <Card className="col-span-4"> <CardHeader> <CardTitle>概要</CardTitle> </CardHeader> <CardContent className="pl-2"> <Suspense fallback={<Skeleton className="h-64 w-full" />}> <Overview data={salesData.chartData} /> </Suspense> </CardContent> </Card> <Card className="col-span-3"> <CardHeader> <CardTitle>最近の売上</CardTitle> <CardDescription> 今月は{salesData.recentSales.length}件の売上がありました。 </CardDescription> </CardHeader> <CardContent> <RecentSales data={salesData.recentSales} /> </CardContent> </Card> </div> </TabsContent> <TabsContent value="analytics" className="space-y-4"> {/* 分析タブの内容 */} </TabsContent> </Tabs> </div> </div> ) } // ローディングコンポーネント export function DashboardSkeleton() { return ( <div className="flex-1 space-y-4 p-8 pt-6"> <Skeleton className="h-8 w-64" /> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> {Array.from({ length: 4 }).map((_, i) => ( <Card key={i}> <CardHeader> <Skeleton className="h-4 w-32" /> </CardHeader> <CardContent> <Skeleton className="h-8 w-24" /> <Skeleton className="h-3 w-20 mt-2" /> </CardContent> </Card> ))} </div> </div> ) }

テスト戦略とアクセシビリティ

コンポーネントテストの実装

// __tests__/components/ui/button.test.tsx import { render, screen, fireEvent } from '@testing-library/react' import { Button } from '@/components/ui/button' describe('Button Component', () => { it('renders with correct text', () => { render(<Button>テストボタン</Button>) expect(screen.getByRole('button', { name: 'テストボタン' })).toBeInTheDocument() }) it('handles click events', () => { const handleClick = jest.fn() render(<Button onClick={handleClick}>クリック</Button>) fireEvent.click(screen.getByRole('button')) expect(handleClick).toHaveBeenCalledTimes(1) }) it('shows loading state correctly', () => { render(<Button loading>読み込み中</Button>) expect(screen.getByText('読み込み中...')).toBeInTheDocument() expect(screen.getByRole('button')).toBeDisabled() }) it('applies variant classes correctly', () => { render(<Button variant="destructive">削除</Button>) const button = screen.getByRole('button') expect(button).toHaveClass('bg-destructive') }) })

アクセシビリティの確保

// components/ui/accessible-dialog.tsx import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react" import { cn } from "@/lib/utils" const Dialog = DialogPrimitive.Root const DialogTrigger = DialogPrimitive.Trigger const DialogPortal = DialogPrimitive.Portal const DialogClose = DialogPrimitive.Close const DialogOverlay = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( <DialogPrimitive.Overlay ref={ref} className={cn( "fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className )} {...props} /> )) const DialogContent = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> >(({ className, children, ...props }, ref) => ( <DialogPortal> <DialogOverlay /> <DialogPrimitive.Content ref={ref} className={cn( "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", className )} {...props} > {children} <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground" aria-label="ダイアログを閉じる" > <X className="h-4 w-4" /> </DialogPrimitive.Close> </DialogPrimitive.Content> </DialogPortal> )) const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} /> ) const DialogTitle = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> >(({ className, ...props }, ref) => ( <DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} /> )) const DialogDescription = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> >(({ className, ...props }, ref) => ( <DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> )) export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, }

まとめ

shadcn/uiとNext.jsの組み合わせは、モダンなWebアプリケーション開発において革新的なアプローチを提供します。今回ご紹介したベストプラクティスを実践することで:

開発効率の大幅向上

  • コピー&ペーストによる迅速な実装
  • 豊富なコンポーネントライブラリ
  • TypeScriptによる型安全性

優れたユーザーエクスペリエンス

  • アクセシビリティ対応済みのコンポーネント
  • レスポンシブデザインのサポート
  • 美しく一貫性のあるデザインシステム

長期的な保守性

  • カスタマイズ可能なコンポーネント
  • 明確な階層構造と責任分離
  • 包括的なテスト戦略

パフォーマンス最適化

  • Next.jsとの完璧な統合
  • 効率的なコード分割
  • サーバーサイドレンダリング対応

shadcn/uiは単なるUIライブラリではなく、現代的なWebアプリケーション開発のパラダイムシフトを象徴しています。完全にカスタマイズ可能でありながら、迅速な開発を可能にするこのアプローチは、今後のフロントエンド開発の標準になる可能性があります。

皆さんもぜひこれらのベストプラクティスを参考に、より効率的で保守性の高いNext.jsアプリケーションを構築してください。shadcn/uiの柔軟性を活かして、プロジェクトの要件に応じたカスタマイゼーションを行い、ユーザーにとって最高の体験を提供しましょう。

コメントを投稿

メールアドレスは公開されません

最大1000文字まで。マークダウン記法(**太字**、*斜体*、`コード`など)が使用できます

投稿されたコメントは管理者による承認後に表示される場合があります。 不適切な内容は削除される可能性があります。

コメント (0件)

関連記事