shadcn/ui応用ガイド2026 — カスタムコンポーネントと実践テクニック


shadcn/uiは基本的なコンポーネントを提供していますが、実際のプロジェクトでは独自のコンポーネントやカスタマイズが必要になります。この記事では、shadcn/uiの応用テクニックを実践的に解説します。

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

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

npm install @tanstack/react-table
npx shadcn-ui@latest add table
// components/ui/data-table.tsx
'use client';

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  SortingState,
  useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';

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

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

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    onSortingChange: setSorting,
    state: {
      sorting,
    },
  });

  return (
    <div>
      <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">
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <div className="flex items-center justify-end space-x-2 py-4">
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </div>
  );
}

使用例:

'use client';

import { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/ui/data-table';

type User = {
  id: string;
  name: string;
  email: string;
  role: string;
};

const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'name',
    header: 'Name',
  },
  {
    accessorKey: 'email',
    header: 'Email',
  },
  {
    accessorKey: 'role',
    header: 'Role',
  },
];

const data: User[] = [
  { id: '1', name: 'John Doe', email: 'john@example.com', role: 'Admin' },
  { id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'User' },
];

export default function UsersPage() {
  return (
    <div className="container mx-auto py-10">
      <DataTable columns={columns} data={data} />
    </div>
  );
}

ファイルアップロード

// components/ui/file-upload.tsx
'use client';

import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { cn } from '@/lib/utils';
import { Upload, X } from 'lucide-react';
import { Button } from './button';

interface FileUploadProps {
  onFilesChange: (files: File[]) => void;
  maxFiles?: number;
  accept?: Record<string, string[]>;
}

export function FileUpload({ onFilesChange, maxFiles = 1, accept }: FileUploadProps) {
  const [files, setFiles] = useState<File[]>([]);

  const onDrop = useCallback(
    (acceptedFiles: File[]) => {
      const newFiles = [...files, ...acceptedFiles].slice(0, maxFiles);
      setFiles(newFiles);
      onFilesChange(newFiles);
    },
    [files, maxFiles, onFilesChange]
  );

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    maxFiles,
    accept,
  });

  const removeFile = (index: number) => {
    const newFiles = files.filter((_, i) => i !== index);
    setFiles(newFiles);
    onFilesChange(newFiles);
  };

  return (
    <div>
      <div
        {...getRootProps()}
        className={cn(
          'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
          isDragActive ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-gray-400'
        )}
      >
        <input {...getInputProps()} />
        <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
        {isDragActive ? (
          <p>Drop the files here...</p>
        ) : (
          <p>Drag & drop files here, or click to select files</p>
        )}
      </div>

      {files.length > 0 && (
        <ul className="mt-4 space-y-2">
          {files.map((file, index) => (
            <li
              key={index}
              className="flex items-center justify-between p-2 bg-gray-50 rounded"
            >
              <span className="text-sm truncate">{file.name}</span>
              <Button
                variant="ghost"
                size="sm"
                onClick={() => removeFile(index)}
              >
                <X className="h-4 w-4" />
              </Button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

ステッパー(マルチステップフォーム)

// components/ui/stepper.tsx
'use client';

import { createContext, useContext, useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from './button';

interface StepperContextType {
  currentStep: number;
  totalSteps: number;
  goToStep: (step: number) => void;
  nextStep: () => void;
  prevStep: () => void;
}

const StepperContext = createContext<StepperContextType | undefined>(undefined);

export function Stepper({ children, steps }: { children: React.ReactNode; steps: number }) {
  const [currentStep, setCurrentStep] = useState(0);

  const goToStep = (step: number) => {
    if (step >= 0 && step < steps) {
      setCurrentStep(step);
    }
  };

  const nextStep = () => goToStep(currentStep + 1);
  const prevStep = () => goToStep(currentStep - 1);

  return (
    <StepperContext.Provider
      value={{
        currentStep,
        totalSteps: steps,
        goToStep,
        nextStep,
        prevStep,
      }}
    >
      {children}
    </StepperContext.Provider>
  );
}

export function StepperHeader({ children }: { children: React.ReactNode }) {
  return <div className="flex items-center justify-between mb-8">{children}</div>;
}

export function StepperStep({ index, children }: { index: number; children: React.ReactNode }) {
  const context = useContext(StepperContext);
  if (!context) throw new Error('StepperStep must be used within Stepper');

  const { currentStep, goToStep } = context;
  const isActive = currentStep === index;
  const isCompleted = currentStep > index;

  return (
    <button
      onClick={() => goToStep(index)}
      className={cn(
        'flex items-center space-x-2 px-4 py-2 rounded-lg transition-colors',
        isActive && 'bg-primary text-primary-foreground',
        isCompleted && 'bg-green-100 text-green-700',
        !isActive && !isCompleted && 'bg-gray-100 text-gray-500'
      )}
    >
      <div
        className={cn(
          'w-8 h-8 rounded-full flex items-center justify-center font-semibold',
          isActive && 'bg-white text-primary',
          isCompleted && 'bg-green-500 text-white',
          !isActive && !isCompleted && 'bg-gray-300 text-gray-600'
        )}
      >
        {index + 1}
      </div>
      <span>{children}</span>
    </button>
  );
}

export function StepperContent({ children }: { children: React.ReactNode }) {
  return <div className="my-8">{children}</div>;
}

export function StepperActions({ children }: { children: React.ReactNode }) {
  return <div className="flex justify-between mt-8">{children}</div>;
}

export function useStepper() {
  const context = useContext(StepperContext);
  if (!context) throw new Error('useStepper must be used within Stepper');
  return context;
}

使用例:

'use client';

import {
  Stepper,
  StepperHeader,
  StepperStep,
  StepperContent,
  StepperActions,
  useStepper,
} from '@/components/ui/stepper';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

export default function MultiStepForm() {
  return (
    <Stepper steps={3}>
      <StepperHeader>
        <StepperStep index={0}>Account</StepperStep>
        <StepperStep index={1}>Profile</StepperStep>
        <StepperStep index={2}>Confirm</StepperStep>
      </StepperHeader>

      <StepperContent>
        <StepContent />
      </StepperContent>

      <StepperActions>
        <NavigationButtons />
      </StepperActions>
    </Stepper>
  );
}

function StepContent() {
  const { currentStep } = useStepper();

  switch (currentStep) {
    case 0:
      return (
        <div className="space-y-4">
          <h2 className="text-2xl font-bold">Account Information</h2>
          <Input placeholder="Email" type="email" />
          <Input placeholder="Password" type="password" />
        </div>
      );
    case 1:
      return (
        <div className="space-y-4">
          <h2 className="text-2xl font-bold">Profile Details</h2>
          <Input placeholder="Full Name" />
          <Input placeholder="Phone" />
        </div>
      );
    case 2:
      return (
        <div className="space-y-4">
          <h2 className="text-2xl font-bold">Confirm</h2>
          <p>Please review your information and submit.</p>
        </div>
      );
    default:
      return null;
  }
}

function NavigationButtons() {
  const { currentStep, totalSteps, nextStep, prevStep } = useStepper();

  return (
    <>
      <Button
        variant="outline"
        onClick={prevStep}
        disabled={currentStep === 0}
      >
        Previous
      </Button>
      <Button onClick={nextStep} disabled={currentStep === totalSteps - 1}>
        {currentStep === totalSteps - 1 ? 'Submit' : 'Next'}
      </Button>
    </>
  );
}

高度なテーマカスタマイズ

グラデーションカラー

/* app/globals.css */
@layer base {
  :root {
    --gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    --gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);
    --gradient-warning: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
  }
}
// components/ui/gradient-button.tsx
import { Button } from './button';
import { cn } from '@/lib/utils';

export function GradientButton({ children, className, ...props }: React.ComponentProps<typeof Button>) {
  return (
    <Button
      className={cn(
        'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600',
        className
      )}
      {...props}
    >
      {children}
    </Button>
  );
}

ガラスモーフィズム

// components/ui/glass-card.tsx
import { Card } from './card';
import { cn } from '@/lib/utils';

export function GlassCard({ children, className, ...props }: React.ComponentProps<typeof Card>) {
  return (
    <Card
      className={cn(
        'backdrop-blur-lg bg-white/30 dark:bg-gray-900/30 border border-white/20 shadow-xl',
        className
      )}
      {...props}
    >
      {children}
    </Card>
  );
}

ニューモーフィズム

/* app/globals.css */
.neumorphic {
  background: #e0e5ec;
  box-shadow:
    9px 9px 16px rgba(163, 177, 198, 0.6),
    -9px -9px 16px rgba(255, 255, 255, 0.5);
}

.neumorphic-inset {
  box-shadow:
    inset 9px 9px 16px rgba(163, 177, 198, 0.6),
    inset -9px -9px 16px rgba(255, 255, 255, 0.5);
}

アニメーション

Framer Motionとの統合

npm install framer-motion
// components/ui/animated-card.tsx
'use client';

import { motion } from 'framer-motion';
import { Card } from './card';

export function AnimatedCard({ children, ...props }: React.ComponentProps<typeof Card>) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -20 }}
      transition={{ duration: 0.3 }}
    >
      <Card {...props}>{children}</Card>
    </motion.div>
  );
}

スケルトンローディング

// components/ui/skeleton-card.tsx
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardHeader } from '@/components/ui/card';

export function SkeletonCard() {
  return (
    <Card>
      <CardHeader>
        <Skeleton className="h-4 w-[250px]" />
        <Skeleton className="h-4 w-[200px]" />
      </CardHeader>
      <CardContent className="space-y-2">
        <Skeleton className="h-4 w-full" />
        <Skeleton className="h-4 w-full" />
        <Skeleton className="h-4 w-2/3" />
      </CardContent>
    </Card>
  );
}

ページトランジション

// components/page-transition.tsx
'use client';

import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';

export function PageTransition({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  return (
    <AnimatePresence mode="wait">
      <motion.div
        key={pathname}
        initial={{ opacity: 0, x: -20 }}
        animate={{ opacity: 1, x: 0 }}
        exit={{ opacity: 0, x: 20 }}
        transition={{ duration: 0.3 }}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  );
}

パフォーマンス最適化

遅延読み込み

import dynamic from 'next/dynamic';

const HeavyComponent = dynamic(() => import('@/components/heavy-component'), {
  loading: () => <SkeletonCard />,
  ssr: false,
});

メモ化

'use client';

import { memo, useMemo } from 'react';
import { DataTable } from '@/components/ui/data-table';

const MemoizedDataTable = memo(DataTable);

export function OptimizedTable({ data }: { data: any[] }) {
  const columns = useMemo(() => [
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'email', header: 'Email' },
  ], []);

  return <MemoizedDataTable columns={columns} data={data} />;
}

バーチャルスクロール

npm install @tanstack/react-virtual
'use client';

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

export function VirtualList({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
  });

  return (
    <div ref={parentRef} className="h-[600px] overflow-auto">
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            {items[virtualRow.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

アクセシビリティ

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

'use client';

import { useCallback } from 'react';

export function useKeyboardNavigation() {
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        // 次の要素にフォーカス
        break;
      case 'ArrowUp':
        e.preventDefault();
        // 前の要素にフォーカス
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        // 選択
        break;
      case 'Escape':
        // 閉じる
        break;
    }
  }, []);

  return { handleKeyDown };
}

スクリーンリーダー対応

<button
  aria-label="Close dialog"
  aria-describedby="dialog-description"
  onClick={onClose}
>
  <X className="h-4 w-4" />
</button>

まとめ

shadcn/uiの応用テクニック:

カスタムコンポーネント:

  • データテーブル(TanStack Table)
  • ファイルアップロード
  • ステッパー(マルチステップフォーム)

テーマカスタマイズ:

  • グラデーション
  • ガラスモーフィズム
  • ニューモーフィズム

アニメーション:

  • Framer Motion統合
  • ページトランジション
  • スケルトンローディング

パフォーマンス:

  • 遅延読み込み
  • メモ化
  • バーチャルスクロール

これらのテクニックを使って、shadcn/uiをさらに活用しましょう。