shadcn/uiは、Radix UIプリミティブをベースに、Tailwind CSSでスタイリングされたコンポーネントコレクションです。従来のUIライブラリとは異なり、コンポーネントのソースコードを直接プロジェクトにコピーするという斬新なアプローチを採用しています。

2026年にリリースされたv3では、Tailwind CSS v4への完全対応、React Server Components最適化、新しいテーマシステムなど、大幅なアップデートが行われました。本記事では、shadcn/ui v3の導入から実践的な活用方法まで包括的に解説します。

shadcn/uiの設計思想

「ライブラリではない」アプローチ

shadcn/uiの最大の特徴は、npm パッケージとしてインストールするのではなく、コンポーネントのソースコードをプロジェクトにコピーする点です。

従来のUIライブラリ:
  npm install some-ui-lib
  → node_modules/some-ui-lib/Button.js(変更不可)
  → カスタマイズ: propsやCSSオーバーライドで対応

shadcn/ui:
  npx shadcn@latest add button
  → src/components/ui/button.tsx(自由に編集可能)
  → カスタマイズ: ソースコードを直接編集

メリットとデメリット

項目shadcn/ui従来のUIライブラリ
カスタマイズ性ソースコード直接編集可能props/CSSオーバーライド
バンドルサイズ使用分のみツリーシェイキング依存
アップデート手動(差分適用)npm update
学習コストRadix UI + Tailwindの知識必要APIドキュメント参照
アクセシビリティRadix UIが保証ライブラリ依存
型安全性TypeScript完全対応ライブラリ依存

セットアップ

Next.js プロジェクトへの導入

# 新規Next.jsプロジェクト作成
npx create-next-app@latest my-app --typescript --tailwind --app

cd my-app

# shadcn/ui v3 初期化
npx shadcn@latest init

# 対話式セットアップ
# ✔ Which style would you like to use? › New York
# ✔ Which color would you like to use as base color? › Zinc
# ✔ Would you like to use CSS variables for colors? › yes

components.json の設定

初期化後に生成されるcomponents.jsonがshadcn/uiの設定ファイルです。

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "src/app/globals.css",
    "baseColor": "zinc",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "iconLibrary": "lucide"
}

Tailwind CSS v4 との統合

shadcn/ui v3はTailwind CSS v4にネイティブ対応しています。

/* src/app/globals.css */
@import "tailwindcss";

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-destructive-foreground: var(--destructive-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
}

@layer base {
  :root {
    --background: oklch(1 0 0);
    --foreground: oklch(0.145 0.004 285.82);
    --card: oklch(1 0 0);
    --card-foreground: oklch(0.145 0.004 285.82);
    --popover: oklch(1 0 0);
    --popover-foreground: oklch(0.145 0.004 285.82);
    --primary: oklch(0.205 0.006 285.82);
    --primary-foreground: oklch(0.985 0 0);
    --secondary: oklch(0.965 0.001 285.82);
    --secondary-foreground: oklch(0.205 0.006 285.82);
    --muted: oklch(0.965 0.001 285.82);
    --muted-foreground: oklch(0.556 0.007 285.82);
    --accent: oklch(0.965 0.001 285.82);
    --accent-foreground: oklch(0.205 0.006 285.82);
    --destructive: oklch(0.577 0.245 27.33);
    --destructive-foreground: oklch(0.577 0.245 27.33);
    --border: oklch(0.922 0.004 285.82);
    --input: oklch(0.922 0.004 285.82);
    --ring: oklch(0.708 0.01 285.82);
    --radius: 0.625rem;
  }

  .dark {
    --background: oklch(0.145 0.004 285.82);
    --foreground: oklch(0.985 0 0);
    --primary: oklch(0.985 0 0);
    --primary-foreground: oklch(0.205 0.006 285.82);
    /* ...ダークモード用の残りの変数 */
  }
}

コンポーネントの追加と使用

基本的なコンポーネント追加

# 個別追加
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialog
npx shadcn@latest add form
npx shadcn@latest add input
npx shadcn@latest add table

# 複数まとめて追加
npx shadcn@latest add button card dialog form input table

# 全コンポーネント追加
npx shadcn@latest add --all

Button コンポーネント

// src/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 gap-2 whitespace-nowrap 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
  {
    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 }

使用例

// src/app/page.tsx
import { Button } from "@/components/ui/button"
import { Loader2, Mail, Github } from "lucide-react"

export default function Home() {
  return (
    <div className="flex flex-col gap-4 p-8">
      {/* 基本バリアント */}
      <Button>デフォルト</Button>
      <Button variant="secondary">セカンダリ</Button>
      <Button variant="destructive">削除</Button>
      <Button variant="outline">アウトライン</Button>
      <Button variant="ghost">ゴースト</Button>
      <Button variant="link">リンク</Button>

      {/* サイズ */}
      <Button size="sm">小さい</Button>
      <Button size="default">標準</Button>
      <Button size="lg">大きい</Button>

      {/* アイコン付き */}
      <Button>
        <Mail /> メール送信
      </Button>

      {/* ローディング状態 */}
      <Button disabled>
        <Loader2 className="animate-spin" />
        処理中...
      </Button>

      {/* asChild: リンクをボタンスタイルで */}
      <Button asChild>
        <a href="/login">ログイン</a>
      </Button>
    </div>
  )
}

フォーム構築(React Hook Form + Zod統合)

セットアップ

npx shadcn@latest add form input select textarea checkbox
npm install react-hook-form @hookform/resolvers zod

実践的なフォーム実装

// src/components/user-profile-form.tsx
"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
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 { Textarea } from "@/components/ui/textarea"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "sonner"

const profileSchema = z.object({
  username: z
    .string()
    .min(3, "ユーザー名は3文字以上で入力してください")
    .max(20, "ユーザー名は20文字以下で入力してください")
    .regex(/^[a-zA-Z0-9_]+$/, "英数字とアンダースコアのみ使用可能です"),
  email: z.string().email("有効なメールアドレスを入力してください"),
  bio: z.string().max(160, "自己紹介は160文字以内で入力してください").optional(),
  role: z.enum(["developer", "designer", "pm", "other"], {
    required_error: "役職を選択してください",
  }),
  notifications: z.object({
    email: z.boolean().default(true),
    push: z.boolean().default(false),
  }),
})

type ProfileFormValues = z.infer<typeof profileSchema>

export function UserProfileForm() {
  const form = useForm<ProfileFormValues>({
    resolver: zodResolver(profileSchema),
    defaultValues: {
      username: "",
      email: "",
      bio: "",
      notifications: {
        email: true,
        push: false,
      },
    },
  })

  async function onSubmit(data: ProfileFormValues) {
    try {
      const response = await fetch("/api/profile", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      })

      if (!response.ok) throw new Error("更新に失敗しました")

      toast.success("プロフィールを更新しました")
    } catch (error) {
      toast.error("エラーが発生しました")
    }
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>ユーザー名</FormLabel>
              <FormControl>
                <Input placeholder="tanaka_taro" {...field} />
              </FormControl>
              <FormDescription>
                英数字とアンダースコアのみ使用できます
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>メールアドレス</FormLabel>
              <FormControl>
                <Input
                  type="email"
                  placeholder="taro@example.com"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="bio"
          render={({ field }) => (
            <FormItem>
              <FormLabel>自己紹介</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="あなたについて教えてください"
                  className="resize-none"
                  {...field}
                />
              </FormControl>
              <FormDescription>
                {field.value?.length || 0}/160文字
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="role"
          render={({ field }) => (
            <FormItem>
              <FormLabel>役職</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="役職を選択" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="developer">開発者</SelectItem>
                  <SelectItem value="designer">デザイナー</SelectItem>
                  <SelectItem value="pm">プロジェクトマネージャー</SelectItem>
                  <SelectItem value="other">その他</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        <div className="space-y-3">
          <FormLabel>通知設定</FormLabel>
          <FormField
            control={form.control}
            name="notifications.email"
            render={({ field }) => (
              <FormItem className="flex items-center gap-2">
                <FormControl>
                  <Checkbox
                    checked={field.value}
                    onCheckedChange={field.onChange}
                  />
                </FormControl>
                <FormLabel className="font-normal">メール通知を受け取る</FormLabel>
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name="notifications.push"
            render={({ field }) => (
              <FormItem className="flex items-center gap-2">
                <FormControl>
                  <Checkbox
                    checked={field.value}
                    onCheckedChange={field.onChange}
                  />
                </FormControl>
                <FormLabel className="font-normal">プッシュ通知を受け取る</FormLabel>
              </FormItem>
            )}
          />
        </div>

        <Button type="submit" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? "保存中..." : "プロフィールを保存"}
        </Button>
      </form>
    </Form>
  )
}

データテーブル(TanStack Table統合)

セットアップ

npx shadcn@latest add table badge dropdown-menu
npm install @tanstack/react-table

高機能データテーブルの実装

// src/components/data-table/columns.tsx
"use client"

import { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ArrowUpDown, MoreHorizontal } from "lucide-react"

export type Article = {
  id: string
  title: string
  status: "draft" | "published" | "archived"
  author: string
  views: number
  createdAt: string
}

export const columns: ColumnDef<Article>[] = [
  {
    accessorKey: "title",
    header: ({ column }) => (
      <Button
        variant="ghost"
        onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
      >
        タイトル
        <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
    cell: ({ row }) => (
      <div className="max-w-[300px] truncate font-medium">
        {row.getValue("title")}
      </div>
    ),
  },
  {
    accessorKey: "status",
    header: "ステータス",
    cell: ({ row }) => {
      const status = row.getValue("status") as string
      const variant = {
        draft: "secondary",
        published: "default",
        archived: "outline",
      }[status] as "secondary" | "default" | "outline"

      const label = {
        draft: "下書き",
        published: "公開中",
        archived: "アーカイブ",
      }[status]

      return <Badge variant={variant}>{label}</Badge>
    },
    filterFn: (row, id, value) => value.includes(row.getValue(id)),
  },
  {
    accessorKey: "author",
    header: "著者",
  },
  {
    accessorKey: "views",
    header: ({ column }) => (
      <Button
        variant="ghost"
        onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
      >
        閲覧数
        <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
    cell: ({ row }) => {
      const views = row.getValue("views") as number
      return <div className="text-right">{views.toLocaleString()}</div>
    },
  },
  {
    id: "actions",
    cell: ({ row }) => {
      const article = row.original

      return (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" className="h-8 w-8 p-0">
              <MoreHorizontal className="h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuLabel>操作</DropdownMenuLabel>
            <DropdownMenuItem
              onClick={() => navigator.clipboard.writeText(article.id)}
            >
              IDをコピー
            </DropdownMenuItem>
            <DropdownMenuSeparator />
            <DropdownMenuItem>編集</DropdownMenuItem>
            <DropdownMenuItem>プレビュー</DropdownMenuItem>
            <DropdownMenuItem className="text-destructive">
              削除
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    },
  },
]
// src/components/data-table/data-table.tsx
"use client"

import * as React from "react"
import {
  ColumnDef,
  ColumnFiltersState,
  SortingState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
}

export function DataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = React.useState<SortingState>([])
  const [columnFilters, setColumnFilters] =
    React.useState<ColumnFiltersState>([])

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    state: { sorting, columnFilters },
  })

  return (
    <div className="space-y-4">
      <Input
        placeholder="タイトルで検索..."
        value={(table.getColumn("title")?.getFilterValue() as string) ?? ""}
        onChange={(event) =>
          table.getColumn("title")?.setFilterValue(event.target.value)
        }
        className="max-w-sm"
      />

      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext()
                      )}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell
                  colSpan={columns.length}
                  className="h-24 text-center"
                >
                  データがありません
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>

      <div className="flex items-center justify-between">
        <p className="text-sm text-muted-foreground">
          {table.getFilteredRowModel().rows.length}件中{" "}
          {table.getState().pagination.pageIndex *
            table.getState().pagination.pageSize +
            1}
          -
          {Math.min(
            (table.getState().pagination.pageIndex + 1) *
              table.getState().pagination.pageSize,
            table.getFilteredRowModel().rows.length
          )}
          件を表示
        </p>
        <div className="flex gap-2">
          <Button
            variant="outline"
            size="sm"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            前へ
          </Button>
          <Button
            variant="outline"
            size="sm"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            次へ
          </Button>
        </div>
      </div>
    </div>
  )
}

テーマのカスタマイズ

カスタムテーマの作成

/* src/app/globals.css に追加 */

/* ブルーテーマ */
.theme-blue {
  --primary: oklch(0.546 0.245 262.88);
  --primary-foreground: oklch(0.985 0 0);
  --accent: oklch(0.932 0.032 255.59);
  --accent-foreground: oklch(0.293 0.066 243.16);
  --ring: oklch(0.546 0.245 262.88);
}

/* グリーンテーマ */
.theme-green {
  --primary: oklch(0.627 0.194 149.21);
  --primary-foreground: oklch(0.985 0 0);
  --accent: oklch(0.925 0.049 158.2);
  --accent-foreground: oklch(0.266 0.065 152.93);
  --ring: oklch(0.627 0.194 149.21);
}

テーマスイッチャーの実装

// src/components/theme-switcher.tsx
"use client"

import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Sun, Moon, Palette } from "lucide-react"

const themes = [
  { name: "デフォルト", value: "", color: "#71717a" },
  { name: "ブルー", value: "theme-blue", color: "#3b82f6" },
  { name: "グリーン", value: "theme-green", color: "#22c55e" },
]

export function ThemeSwitcher() {
  const { theme, setTheme } = useTheme()

  return (
    <div className="flex items-center gap-2">
      {/* ライト/ダーク切り替え */}
      <Button
        variant="ghost"
        size="icon"
        onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
      >
        <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
        <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
      </Button>

      {/* カラーテーマ切り替え */}
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" size="icon">
            <Palette className="h-5 w-5" />
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent>
          {themes.map((t) => (
            <DropdownMenuItem
              key={t.value}
              onClick={() => {
                document.documentElement.className =
                  document.documentElement.className
                    .replace(/theme-\w+/g, "")
                    .trim() + (t.value ? ` ${t.value}` : "")
              }}
            >
              <div
                className="mr-2 h-4 w-4 rounded-full"
                style={{ backgroundColor: t.color }}
              />
              {t.name}
            </DropdownMenuItem>
          ))}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  )
}

カスタムコンポーネントの作成

ステップウィザード

shadcn/uiのプリミティブを組み合わせて、独自のコンポーネントを作成できます。

// src/components/ui/stepper.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { Check } from "lucide-react"

interface StepperProps {
  steps: { title: string; description?: string }[]
  currentStep: number
  className?: string
}

export function Stepper({ steps, currentStep, className }: StepperProps) {
  return (
    <div className={cn("flex w-full items-center", className)}>
      {steps.map((step, index) => (
        <React.Fragment key={index}>
          <div className="flex flex-col items-center gap-2">
            <div
              className={cn(
                "flex h-10 w-10 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors",
                index < currentStep
                  ? "border-primary bg-primary text-primary-foreground"
                  : index === currentStep
                    ? "border-primary text-primary"
                    : "border-muted text-muted-foreground"
              )}
            >
              {index < currentStep ? (
                <Check className="h-5 w-5" />
              ) : (
                index + 1
              )}
            </div>
            <div className="text-center">
              <p
                className={cn(
                  "text-sm font-medium",
                  index <= currentStep
                    ? "text-foreground"
                    : "text-muted-foreground"
                )}
              >
                {step.title}
              </p>
              {step.description && (
                <p className="text-xs text-muted-foreground">
                  {step.description}
                </p>
              )}
            </div>
          </div>
          {index < steps.length - 1 && (
            <div
              className={cn(
                "mx-2 h-0.5 flex-1 transition-colors",
                index < currentStep ? "bg-primary" : "bg-muted"
              )}
            />
          )}
        </React.Fragment>
      ))}
    </div>
  )
}
// 使用例
import { Stepper } from "@/components/ui/stepper"

const steps = [
  { title: "アカウント", description: "基本情報の入力" },
  { title: "プラン選択", description: "料金プランの選択" },
  { title: "お支払い", description: "決済情報の入力" },
  { title: "完了", description: "登録完了" },
]

export function SignupWizard() {
  const [currentStep, setCurrentStep] = React.useState(1)

  return (
    <div className="mx-auto max-w-2xl space-y-8 p-8">
      <Stepper steps={steps} currentStep={currentStep} />
      {/* 各ステップのコンテンツ */}
    </div>
  )
}

アクセシビリティ

shadcn/uiはRadix UIをベースにしているため、WAI-ARIA準拠のアクセシビリティが標準で組み込まれています。

キーボードナビゲーション

// Dialogコンポーネントは自動的にフォーカストラップを実装
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"

export function AccessibleDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button>設定を開く</Button>
      </DialogTrigger>
      <DialogContent>
        {/* 自動的にフォーカストラップ・Escキー閉じ対応 */}
        <DialogHeader>
          <DialogTitle>設定</DialogTitle>
          <DialogDescription>
            アプリケーションの設定を変更できます
          </DialogDescription>
        </DialogHeader>
        {/* フォーム内容 */}
      </DialogContent>
    </Dialog>
  )
}

スクリーンリーダー対応

// visually-hiddenパターン
import { Label } from "@/components/ui/label"

export function SearchInput() {
  return (
    <div>
      {/* スクリーンリーダーには読まれるが視覚的には非表示 */}
      <Label htmlFor="search" className="sr-only">
        記事を検索
      </Label>
      <Input id="search" placeholder="検索..." />
    </div>
  )
}

パフォーマンス最適化

Server Components対応

// サーバーコンポーネントとして使用可能なもの
// Badge, Card, Table, Separator など

// src/app/articles/page.tsx (Server Component)
import { Badge } from "@/components/ui/badge"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

export default async function ArticlesPage() {
  const articles = await fetchArticles() // サーバーサイドfetch

  return (
    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
      {articles.map((article) => (
        <Card key={article.id}>
          <CardHeader>
            <div className="flex items-center justify-between">
              <CardTitle className="text-lg">{article.title}</CardTitle>
              <Badge>{article.status}</Badge>
            </div>
            <CardDescription>{article.excerpt}</CardDescription>
          </CardHeader>
          <CardContent>
            <p className="text-sm text-muted-foreground">
              {article.createdAt}
            </p>
          </CardContent>
        </Card>
      ))}
    </div>
  )
}

動的インポート

// 重いコンポーネントは動的インポート
import dynamic from "next/dynamic"

const DataTable = dynamic(
  () => import("@/components/data-table/data-table").then((mod) => mod.DataTable),
  {
    loading: () => (
      <div className="flex h-96 items-center justify-center">
        <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
      </div>
    ),
  }
)

まとめ

shadcn/ui v3は、2026年のReact開発における最も実践的なUIコンポーネントソリューションの一つです。本記事で紹介した内容をまとめます。

  • 設計思想: コンポーネントのソースコードを直接所有するアプローチにより、完全なカスタマイズ自由度を実現
  • Tailwind CSS v4統合: oklch色空間、CSS変数ベースのテーマシステムにより、一貫性のあるデザインシステムを構築可能
  • フォーム構築: React Hook Form + Zodとの統合により、型安全なバリデーション付きフォームを効率的に実装可能
  • データテーブル: TanStack Tableとの組み合わせで、ソート・フィルタ・ページネーション対応の高機能テーブルを構築可能
  • アクセシビリティ: Radix UIベースにより、WAI-ARIA準拠のアクセシビリティが標準で組み込まれている
  • パフォーマンス: React Server Components対応、動的インポートにより、初期ロード時間を最適化可能

shadcn/uiを導入する際は、まずcomponents.jsonの設定を丁寧に行い、プロジェクトのデザインシステムに合わせたテーマカスタマイズを最初に済ませることが、スムーズな開発の鍵となります。

関連記事