T-CREATOR

shadcn/ui の思想を徹底解剖:なぜ「コピーして使う」アプローチが拡張性に強いのか

shadcn/ui の思想を徹底解剖:なぜ「コピーして使う」アプローチが拡張性に強いのか

React の UI 開発において、shadcn/ui は従来のコンポーネントライブラリとは一線を画す革新的なアプローチで注目を集めています。npm パッケージとしてインストールするのではなく、「コピーして使う」という斬新な思想で開発者コミュニティに大きな衝撃を与えました。

この記事では、shadcn/ui がなぜこのような独特なアプローチを採用したのか、そしてそれがどのように拡張性と保守性の向上につながるのかを詳しく解説します。従来の UI ライブラリの制約から解放される新しい開発体験を、具体的な実装例とともにご紹介していきましょう。

背景

従来の UI ライブラリの課題

React エコシステムにおいて、UI ライブラリは長らく開発効率向上の重要な要素でした。Material-UI(現 MUI)、Ant Design、Chakra UI など、多くの優秀なライブラリが開発現場を支えてきています。

しかし、これらの従来型ライブラリには共通する構造的な問題が存在していました。以下の図で、従来のライブラリアーキテクチャの構造を示します。

mermaidflowchart TB
  app[アプリケーション] --> lib[UI ライブラリ]
  lib --> deps[大量の依存関係]
  deps --> core[コアライブラリ]
  deps --> styles[スタイルシステム]
  deps --> icons[アイコンセット]

  lib --> bundle[バンドルサイズ増加]
  lib --> lock[ベンダーロックイン]
  lib --> custom[カスタマイズ制約]

従来のライブラリでは、開発者は完全なパッケージを依存関係として追加する必要がありました。これにより、使用しない機能も含めた全体がプロジェクトに組み込まれ、バンドルサイズの増大やアップデート時の破壊的変更のリスクが生じていたのです。

コンポーネントライブラリの進化

UI ライブラリの進化を振り返ると、大きく 3 つの段階に分けることができます。

#段階特徴代表例
1第一世代モノリシックなライブラリBootstrap、Foundation
2第二世代コンポーネント指向Material-UI、Ant Design
3第三世代ヘッドレス+コピーアプローチshadcn/ui、Radix UI

第三世代の特徴は、UI ロジックとスタイルを分離し、開発者がより柔軟にカスタマイズできる環境を提供することです。shadcn/ui はこの第三世代の代表格として、まったく新しいパラダイムを提示しました。

課題

既存ライブラリの制約

従来の UI ライブラリを使用する際、開発者は以下のような制約に直面することが多々ありました。

デザインシステムの固定化

Material-UI を例に取ると、Google のマテリアルデザインの思想が強く反映されています。独自のデザインシステムを構築したい場合、大幅なオーバーライドが必要となり、結果的にライブラリの恩恵を受けにくくなってしまいます。

typescript// Material-UI でのカスタマイズ例(複雑な設定が必要)
import {
  createTheme,
  ThemeProvider,
} from '@mui/material/styles';

const customTheme = createTheme({
  palette: {
    primary: {
      main: '#custom-color',
    },
  },
  components: {
    MuiButton: {
      styleOverrides: {
        root: {
          // 大量のオーバーライド設定...
        },
      },
    },
  },
});

バンドルサイズの課題

多機能なライブラリほど、使用しない機能も含めてバンドルに含まれがちです。Tree Shaking が効かない部分も多く、最終的なアプリケーションサイズが肥大化する傾向にありました。

依存関係の問題

現代のフロントエンド開発において、依存関係の管理は複雑さを増し続けています。以下の図で、典型的な依存関係の構造を示します。

mermaidflowchart LR
  project[プロジェクト] --> uiLib[UI ライブラリ]
  uiLib --> emotion[Emotion/Styled]
  uiLib --> react[React]
  uiLib --> utils[Utility 群]

  emotion --> deps1[依存関係群]
  utils --> deps2[依存関係群]

  deps1 --> security[セキュリティリスク]
  deps2 --> version[バージョン競合]

  uiLib --> update[アップデート時]
  update --> breaking[破壊的変更]
  breaking --> migration[マイグレーション作業]

特に問題となるのは、ライブラリのメジャーアップデート時です。内部で使用している依存関係の変更により、予期しない破壊的変更が発生し、大規模な修正作業が必要になることも珍しくありません。

カスタマイズの限界

従来のライブラリでカスタマイズを行う際、以下のような課題が発生していました。

CSS-in-JS の制約

typescript// 既存ライブラリでの制約例
const StyledButton = styled(MuiButton)`
  // ライブラリの内部スタイルを上書きする必要
  && {
    background-color: ${(props) => props.theme.primary};
    // 詳細度の問題でスタイルが適用されない場合も...
  }
`;

コンポーネントの内部構造への依存

ライブラリが提供するコンポーネントの内部構造に依存したカスタマイズは、ライブラリのアップデート時に動作しなくなるリスクを抱えています。

解決策

「コピーして使う」思想の核心

shadcn/ui が提唱する「コピーして使う」アプローチは、従来の依存関係モデルを根本的に覆す革新的な考え方です。

以下の図で、shadcn/ui のアーキテクチャと従来のライブラリとの違いを示します。

mermaidflowchart TB
  subgraph traditional [従来のアプローチ]
    app1[アプリケーション] --> package[npm パッケージ]
    package --> blackbox[ブラックボックス]
  end

  subgraph shadcn [shadcn/ui アプローチ]
    app2[アプリケーション] --> cli[shadcn CLI]
    cli --> copy[コンポーネントコピー]
    copy --> source[ソースコード]
    source --> customize[自由なカスタマイズ]
  end

  traditional --> deps[依存関係リスク]
  shadcn --> ownership[完全な所有権]

このアプローチの核心は、開発者がコンポーネントの完全な所有権を持つ ことです。npm パッケージとして提供されるのではなく、必要なコンポーネントのソースコードを直接プロジェクトにコピーします。

CLI ツールによる効率化

shadcn/ui では、専用の CLI ツールが提供されており、必要なコンポーネントを簡単に追加できます。

bash# コンポーネントの追加
npx shadcn-ui@latest add button
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add form

このコマンドを実行すると、指定されたコンポーネントのソースコードがプロジェクトの components​/​ui ディレクトリにコピーされます。

拡張性を重視した設計哲学

shadcn/ui の設計哲学は、拡張性とカスタマイズ性の最大化 にあります。

Radix UI との戦略的連携

shadcn/ui は UI ロジック部分で Radix UI を活用しています。Radix UI はヘッドレス UI ライブラリとして、アクセシビリティやキーボードナビゲーションなどの複雑な UI ロジックを提供します。

typescript// Button コンポーネントの構造例
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',
  {
    variants: {
      variant: {
        default:
          'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive:
          'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        // バリエーションの定義...
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

Class Variance Authority(CVA)の活用

コンポーネントのバリエーション管理には CVA を使用し、TypeScript の型安全性を保ちながら柔軟なスタイリングを実現しています。

開発者の自由度向上

shadcn/ui のアプローチにより、開発者は以下の自由度を獲得できます。

完全なカスタマイズ権限

コンポーネントのソースコードを直接所有するため、どのような修正も自由に行えます。

typescript// 自由なカスタマイズ例
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  // 独自のプロパティを追加
  customBehavior?: boolean;
  analyticsEvent?: string;
}

const Button = React.forwardRef<
  HTMLButtonElement,
  ButtonProps
>(
  (
    {
      className,
      variant,
      size,
      asChild = false,
      customBehavior,
      analyticsEvent,
      ...props
    },
    ref
  ) => {
    const Comp = asChild ? Slot : 'button';

    const handleClick = (
      event: React.MouseEvent<HTMLButtonElement>
    ) => {
      // カスタムロジックの追加
      if (customBehavior) {
        // 独自の処理...
      }

      if (analyticsEvent) {
        // アナリティクス送信...
      }

      props.onClick?.(event);
    };

    return (
      <Comp
        className={cn(
          buttonVariants({ variant, size, className })
        )}
        ref={ref}
        onClick={handleClick}
        {...props}
      />
    );
  }
);

具体例

実際のコンポーネント実装例

shadcn/ui のコンポーネントがどのように実装されているか、実際の Dialog コンポーネントを例に見てみましょう。

基本的な Dialog 構造

typescript// Dialog のインポートと基本設定
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;

カスタムスタイルの適用

typescript// Dialog のオーバーレイ部分
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}
  />
));

コンテンツエリアの実装

typescript// Dialog のメインコンテンツ
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'>
        <X className='h-4 w-4' />
        <span className='sr-only'>Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
));

カスタマイズパターン

shadcn/ui のコンポーネントは、様々なレベルでカスタマイズが可能です。

Level 1:CSS クラスの調整

typescript// 基本的なスタイル調整
<Button className='bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600'>
  グラデーションボタン
</Button>

Level 2:バリアント追加

typescript// buttonVariants に新しいバリアントを追加
const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium',
  {
    variants: {
      variant: {
        default:
          'bg-primary text-primary-foreground hover:bg-primary/90',
        // 独自バリアントの追加
        gradient:
          'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600',
        neon: 'bg-black text-cyan-400 border border-cyan-400 hover:bg-cyan-400 hover:text-black transition-all duration-300 shadow-lg shadow-cyan-400/50',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        // カスタムサイズ
        xl: 'h-14 rounded-lg px-12 text-lg',
      },
    },
  }
);

Level 3:完全なコンポーネント再実装

typescript// プロジェクト固有の要件に合わせた完全カスタマイズ
const CustomButton = React.forwardRef<
  HTMLButtonElement,
  ButtonProps
>(
  (
    {
      className,
      variant,
      size,
      asChild = false,
      loading,
      icon,
      ...props
    },
    ref
  ) => {
    const Comp = asChild ? Slot : 'button';

    return (
      <Comp
        className={cn(
          buttonVariants({ variant, size, className })
        )}
        ref={ref}
        disabled={loading || props.disabled}
        {...props}
      >
        {loading && (
          <Spinner className='mr-2 h-4 w-4 animate-spin' />
        )}
        {icon && !loading && (
          <span className='mr-2'>{icon}</span>
        )}
        {props.children}
      </Comp>
    );
  }
);

他ライブラリとの比較

以下の表で、shadcn/ui と他の主要ライブラリの特徴を比較します。

#項目shadcn/uiMaterial-UIAnt DesignChakra UI
1インストール方法コピー&ペーストnpm パッケージnpm パッケージnpm パッケージ
2バンドルサイズ必要分のみ大きい大きい中程度
3カスタマイズ性完全自由制限あり制限あり高い
4依存関係リスクなしありありあり
5アップデート影響なし破壊的変更あり破壊的変更あり中程度
6学習コスト低い高い中程度中程度

以下の図で、各ライブラリのアプローチの違いを視覚的に示します。

mermaidflowchart TD
  subgraph comparison [ライブラリ比較]
    subgraph mui [Material-UI]
      muiApp[アプリ] --> muiPackage[MUIパッケージ]
      muiPackage --> muiTheme[テーマシステム]
      muiTheme --> muiCustom[限定的カスタマイズ]
    end

    subgraph shadcn [shadcn/ui]
      shadcnApp[アプリ] --> shadcnCLI[CLI]
      shadcnCLI --> shadcnCopy[ソースコピー]
      shadcnCopy --> shadcnFree[完全自由]
    end

    subgraph chakra [Chakra UI]
      chakraApp[アプリ] --> chakraPackage[Chakraパッケージ]
      chakraPackage --> chakraSystem[デザインシステム]
      chakraSystem --> chakraCustom[高いカスタマイズ性]
    end
  end

  muiCustom --> constraint[制約あり]
  shadcnFree --> freedom[完全自由]
  chakraCustom --> balance[バランス型]

この比較から分かるように、shadcn/ui は完全な自由度を提供する一方で、他のライブラリはそれぞれ異なる制約とメリットを持っています。

まとめ

shadcn/ui の「コピーして使う」アプローチは、UI ライブラリの新しいパラダイムを提示しています。従来の npm パッケージモデルから脱却し、開発者にコンポーネントの完全な所有権を与えることで、以下のメリットを実現しました。

主要なメリット

  • 依存関係リスクの完全な排除
  • 無制限のカスタマイズ自由度
  • バンドルサイズの最適化
  • アップデート時の破壊的変更からの解放

拡張性の観点から

  • プロジェクト固有の要件に完全対応
  • 段階的な機能追加が容易
  • チーム独自のデザインシステム構築が可能

この革新的なアプローチは、特に長期的なプロジェクトや、独自性の高いデザインシステムを必要とするプロダクトにおいて、その真価を発揮します。開発者が真の意味でコンポーネントを「所有」することで、制約のない自由な開発体験を提供してくれるのです。

shadcn/ui は単なる UI ライブラリを超えて、フロントエンド開発の新しい可能性を切り開いています。この思想を理解し活用することで、より柔軟で保守性の高いアプリケーション開発が実現できるでしょう。

関連リンク