shadcn/ui で Tailwind クラスが競合する時の対処法:優先度・レイヤ・@apply の整理
shadcn/ui と Tailwind CSS を組み合わせたプロジェクトで、スタイルが思い通りに適用されなかった経験はありませんか?コンポーネントにクラスを追加したのに反映されない、あるいは意図しないスタイルが優先されてしまうといった問題は、多くの開発者が直面する課題です。
この記事では、shadcn/ui で Tailwind クラスが競合する際の対処法を、優先度・レイヤ・@apply の観点から詳しく解説します。基礎的な仕組みから実践的な解決策まで、段階的に理解を深めていきましょう。
背景
Tailwind CSS のクラス競合とは
Tailwind CSS は、ユーティリティファーストの CSS フレームワークとして、クラス名を組み合わせてスタイリングを行います。しかし、同じプロパティに対して複数のクラスが適用されると、どちらが優先されるかが問題になるのです。
以下の図は、Tailwind におけるクラス適用の基本的な流れを示しています。
mermaidflowchart TD
  A["開発者がクラスを記述"] --> B["Tailwind が CSS を生成"]
  B --> C["ブラウザが CSS を解釈"]
  C --> D{"競合するプロパティ"}
  D -->|優先度が高い| E["優先されるスタイルを適用"]
  D -->|優先度が低い| F["無視される"]
図で理解できる要点:
- Tailwind は記述したクラスを CSS に変換しますが、競合時には優先度ルールが適用されます
 - ブラウザの CSS 解釈ルールに従って、最終的なスタイルが決定されます
 
shadcn/ui における特殊性
shadcn/ui は、再利用可能なコンポーネントライブラリとして、事前定義されたスタイルを持っています。これらのスタイルは、プロジェクトにコピーされる形で提供されるため、カスタマイズしやすい反面、クラスの競合が発生しやすい構造になっているのです。
shadcn/ui のコンポーネントは、以下のような特徴を持ちます。
| # | 特徴 | 説明 | 
|---|---|---|
| 1 | コンポーネント内部スタイル | 各コンポーネントに基本スタイルが含まれる | 
| 2 | variants による条件分岐 | class-variance-authority でスタイルを管理 | 
| 3 | cn 関数による結合 | clsx と tailwind-merge でクラスをマージ | 
| 4 | カスタマイズ前提の設計 | 外部からのクラス追加を想定 | 
CSS の詳細度(Specificity)の基本
CSS には「詳細度」という概念があり、これがスタイルの優先順位を決定します。Tailwind のユーティリティクラスは、基本的に同じ詳細度(0,0,1,0)を持つため、記述順序が重要になるのです。
mermaidflowchart LR
  A["インラインスタイル<br/>(1,0,0,0)"] --> B["ID セレクタ<br/>(0,1,0,0)"]
  B --> C["クラス・属性<br/>(0,0,1,0)"]
  C --> D["要素セレクタ<br/>(0,0,0,1)"]
  style A fill:#ff6b6b
  style B fill:#feca57
  style C fill:#48dbfb
  style D fill:#1dd1a1
詳細度の計算ポイント:
- Tailwind のユーティリティクラスはすべて同じクラスレベルの詳細度
 - そのため、CSS ファイル内での出現順序が優先度を決定します
 - 後から定義されたスタイルが前のスタイルを上書きします
 
課題
よくある競合パターン
shadcn/ui を使用する際、以下のような競合パターンに遭遇することが多いです。
パターン 1: コンポーネントのデフォルトスタイルと上書きクラスの競合
shadcn/ui の Button コンポーネントに、独自の背景色を適用しようとした例です。
typescriptimport { Button } from '@/components/ui/button';
export function MyButton() {
  // bg-blue-500 が適用されない!
  return <Button className='bg-blue-500'>クリック</Button>;
}
Button コンポーネント内部では、variants で背景色が定義されているため、外部から追加したbg-blue-500が無視されてしまいます。
パターン 2: レスポンシブクラスの競合
レスポンシブなスタイル指定で、意図しない優先順位になるケースです。
typescript// 意図: スマホでは青、タブレット以上では赤
<div className='bg-blue-500 md:bg-red-500'>コンテンツ</div>
この記述は正常に動作しますが、以下のように順序を逆にすると問題が発生する場合があります。
typescript// md:bg-red-500 が常に優先される可能性
<div className='md:bg-red-500 bg-blue-500'>コンテンツ</div>
パターン 3: カスタム CSS と Tailwind の競合
@applyを使ったカスタムクラスと Tailwind ユーティリティの競合です。
css/* styles.css */
.custom-button {
  @apply bg-green-500 px-4 py-2;
}
typescript// bg-blue-500 が効かない場合がある
<button className='custom-button bg-blue-500'>
  ボタン
</button>
競合が発生する根本原因
これらの競合は、以下の原因で発生します。
mermaidflowchart TD
  A["クラス競合の根本原因"] --> B["CSS 生成順序"]
  A --> C["詳細度の同一性"]
  A --> D["cn 関数のマージロジック"]
  B --> B1["Tailwind の設定順序"]
  B --> B2["レイヤの優先度"]
  C --> C1["すべて 0,0,1,0"]
  C --> C2["後勝ちルール"]
  D --> D1["tailwind-merge の制限"]
  D --> D2["variants の優先"]
根本原因のまとめ:
- Tailwind は設定ファイルとレイヤに基づいて CSS を生成するため、生成順序が重要です
 - すべてのユーティリティクラスが同じ詳細度を持つため、後に記述されたものが優先されます
 - shadcn/ui の
cn関数はtailwind-mergeを使用しますが、すべての競合を解決できるわけではありません 
解決策
解決策 1: cn 関数を正しく理解する
shadcn/ui では、cn関数がクラスの結合とマージを担当しています。この関数の仕組みを理解することが、競合解決の第一歩です。
cn 関数の実装
typescript// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
この関数は 2 つのライブラリを組み合わせています。
| # | ライブラリ | 役割 | 
|---|---|---|
| 1 | clsx | 条件付きクラス名の結合 | 
| 2 | tailwind-merge | 競合する Tailwind クラスのマージ | 
tailwind-merge の動作原理
tailwind-mergeは、同じプロパティに作用するクラスを検出し、後から指定されたクラスを優先します。
typescriptimport { twMerge } from 'tailwind-merge';
// 正常にマージされる例
const result1 = twMerge('bg-blue-500', 'bg-red-500');
// 結果: "bg-red-500" (後勝ち)
// レスポンシブクラスも適切にマージ
const result2 = twMerge('px-4', 'md:px-6', 'lg:px-8');
// 結果: "px-4 md:px-6 lg:px-8"
// 異なるプロパティは両方とも残る
const result3 = twMerge('bg-blue-500', 'text-white');
// 結果: "bg-blue-500 text-white"
以下の図は、cn関数内でのクラスマージの流れを示しています。
mermaidsequenceDiagram
  participant Dev as 開発者
  participant clsx as clsx
  participant twMerge as tailwind-merge
  participant Result as 最終クラス
  Dev->>clsx: 複数のクラスを渡す
  clsx->>clsx: 条件付きクラスを評価
  clsx->>twMerge: 結合された文字列
  twMerge->>twMerge: 競合クラスを検出
  twMerge->>twMerge: 後勝ちルールで解決
  twMerge->>Result: マージ済みクラス
コンポーネントでの正しい使用法
shadcn/ui のコンポーネントでは、以下のようにcn関数を活用します。
typescript// components/ui/button.tsx (簡略版)
import { cn } from '@/lib/utils';
import {
  VariantProps,
  cva,
} from 'class-variance-authority';
const buttonVariants = cva(
  // ベースクラス
  'inline-flex items-center justify-center',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground',
        destructive:
          'bg-destructive text-destructive-foreground',
      },
    },
  }
);
typescript// Button コンポーネント本体
interface ButtonProps
  extends VariantProps<typeof buttonVariants> {
  className?: string;
}
export function Button({
  className,
  variant,
  ...props
}: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant }), className)}
      {...props}
    />
  );
}
この実装では、classNameプロパティが後からcn関数に渡されるため、外部からのクラスが優先されます。
typescript// 使用例: bg-blue-500 が優先される
<Button className='bg-blue-500'>カスタムボタン</Button>
解決策 2: Tailwind のレイヤシステムを活用する
Tailwind CSS には、@layerディレクティブによるレイヤシステムが用意されています。これを理解することで、スタイルの優先順位を制御できます。
3 つの基本レイヤ
Tailwind は、以下の 3 つのレイヤを持ちます。
css/* globals.css */
/* 1. base レイヤ: リセットCSSや基本スタイル */
@layer base {
  h1 {
    @apply text-4xl font-bold;
  }
}
/* 2. components レイヤ: 再利用可能なコンポーネントスタイル */
@layer components {
  .btn-primary {
    @apply bg-blue-500 text-white px-4 py-2 rounded;
  }
}
/* 3. utilities レイヤ: カスタムユーティリティクラス */
@layer utilities {
  .text-shadow {
    text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
  }
}
レイヤの優先順位は以下の通りです。
mermaidflowchart LR
  A["base<br/>(最低)"] --> B["components<br/>(中)"]
  B --> C["utilities<br/>(最高)"]
  style A fill:#e8f5e9
  style B fill:#fff9c4
  style C fill:#ffebee
重要な優先順位ルール:
utilitiesレイヤが最も優先度が高く、通常の Tailwind ユーティリティクラスと同等ですcomponentsレイヤは、ユーティリティクラスで上書き可能ですbaseレイヤは、最も優先度が低くなります
レイヤを使った競合解決
カスタムコンポーネントスタイルをcomponentsレイヤに定義することで、ユーティリティクラスによる上書きが可能になります。
css/* globals.css */
@layer components {
  .custom-card {
    @apply bg-white rounded-lg shadow-md p-6;
    /* デフォルトは白背景 */
  }
}
typescript// bg-blue-100 でカード背景を上書きできる
<div className='custom-card bg-blue-100'>
  <h2>カードタイトル</h2>
  <p>カードの内容</p>
</div>
この方法により、基本スタイルを維持しつつ、必要に応じて柔軟にカスタマイズできるようになります。
解決策 3: @apply の適切な使用
@applyディレクティブは、Tailwind クラスをカスタムクラス内で再利用するための機能です。ただし、使い方を誤ると競合の原因になります。
@apply の基本構文
css/* globals.css */
@layer components {
  .btn-base {
    /* Tailwind ユーティリティを適用 */
    @apply px-4 py-2 rounded font-medium;
    @apply transition-colors duration-200;
  }
}
このbtn-baseクラスは、以下のように展開されます。
css/* 生成される CSS (簡略版) */
.btn-base {
  padding-left: 1rem;
  padding-right: 1rem;
  padding-top: 0.5rem;
  padding-bottom: 0.5rem;
  border-radius: 0.25rem;
  font-weight: 500;
  transition-property: color, background-color, border-color;
  transition-duration: 200ms;
}
@apply 使用時の注意点
@applyを使用する際は、以下のポイントに注意しましょう。
| # | 注意点 | 理由 | 
|---|---|---|
| 1 | レイヤ内で使用する | 優先順位を制御するため | 
| 2 | 過度に使用しない | Tailwind の利点が失われる | 
| 3 | 複雑なバリアントは避ける | メンテナンス性が低下する | 
| 4 | ドキュメント化する | 他の開発者が理解しやすくする | 
誤った @apply の使用例
以下は、@applyを誤って使用した例です。
css/* アンチパターン: utilities レイヤで使用 */
@layer utilities {
  .my-button {
    /* utilities レイヤでは @apply は推奨されない */
    @apply bg-blue-500 text-white;
  }
}
typescript// bg-red-500 が効かない可能性がある
<button className='my-button bg-red-500'>ボタン</button>
正しい @apply の使用例
componentsレイヤで基本スタイルを定義し、ユーティリティクラスで上書き可能にします。
css/* 推奨パターン: components レイヤで使用 */
@layer components {
  .my-button {
    @apply px-4 py-2 rounded font-medium;
    /* 背景色は意図的に指定しない */
  }
}
typescript// 背景色をユーティリティクラスで指定
<button className="my-button bg-blue-500 hover:bg-blue-600">
  青いボタン
</button>
<button className="my-button bg-red-500 hover:bg-red-600">
  赤いボタン
</button>
この方法により、共通スタイルを再利用しつつ、柔軟なカスタマイズが可能になります。
解決策 4: クラス記述順序の最適化
Tailwind では、HTML でのクラス記述順序は優先度に影響しませんが、cn関数やtailwind-mergeを使用する際は順序が重要になります。
推奨されるクラス記述順序
可読性とメンテナンス性を高めるため、以下の順序でクラスを記述することをおすすめします。
typescript<div
  className={cn(
    // 1. レイアウト関連
    'flex items-center justify-between',
    // 2. サイズ関連
    'w-full h-12 px-4 py-2',
    // 3. 装飾関連
    'bg-white border border-gray-200 rounded-lg shadow-sm',
    // 4. テキスト関連
    'text-sm font-medium text-gray-900',
    // 5. インタラクション関連
    'hover:bg-gray-50 focus:outline-none focus:ring-2',
    // 6. レスポンシブ・条件付きクラス
    'md:w-auto md:px-6',
    // 7. カスタムクラス(外部からの props)
    className
  )}
>
  コンテンツ
</div>
以下の図は、クラス適用の優先順序を視覚化したものです。
mermaidflowchart TD
  A["基本クラス"] --> B["レスポンシブクラス"]
  B --> C["外部からの className"]
  C --> D["cn 関数でマージ"]
  D --> E["tailwind-merge が競合解決"]
  E --> F["最終的なクラス文字列"]
  style C fill:#ffe0b2
  style E fill:#c5e1a5
  style F fill:#b3e5fc
条件付きクラスの記述
clsxの機能を活用して、条件に応じたクラスを適用します。
typescriptinterface CardProps {
  variant?: 'default' | 'highlighted';
  className?: string;
}
export function Card({
  variant = 'default',
  className,
}: CardProps) {
  return (
    <div
      className={cn(
        // ベースクラス
        'rounded-lg p-6 shadow-md',
        // variant による条件分岐
        {
          'bg-white border border-gray-200':
            variant === 'default',
          'bg-blue-50 border-2 border-blue-500':
            variant === 'highlighted',
        },
        // 外部からのカスタマイズを最後に
        className
      )}
    >
      カードコンテンツ
    </div>
  );
}
この実装により、classNameプロパティで背景色を上書きできます。
typescript// bg-green-100 が優先される
<Card variant='default' className='bg-green-100'>
  カスタム背景のカード
</Card>
解決策 5: important 修飾子の活用
Tailwind には、!プレフィックスを使ったimportant修飾子が用意されています。これを使用すると、特定のクラスを強制的に優先させることができます。
important 修飾子の基本
typescript// bg-blue-500 を強制的に適用
<Button className='!bg-blue-500'>重要なボタン</Button>
生成される CSS は以下のようになります。
css/* 通常のクラス */
.bg-blue-500 {
  background-color: rgb(59 130 246);
}
/* important 修飾子付き */
.\!bg-blue-500 {
  background-color: rgb(59 130 246) !important;
}
important 修飾子の使用場面
important修飾子は、以下のような場面で有効です。
| # | 使用場面 | 例 | 
|---|---|---|
| 1 | サードパーティライブラリの上書き | !text-sm | 
| 2 | 深くネストされた要素のスタイル強制 | !bg-white | 
| 3 | インラインスタイルの上書き | !p-4 | 
| 4 | レガシーコードとの互換性維持 | !border-none | 
注意: 乱用は避ける
importantは強力ですが、乱用すると以下の問題が発生します。
typescript// アンチパターン: すべてに important を使用
<div className="!bg-white !p-4 !rounded-lg !shadow-md">
  <!-- 後からカスタマイズできなくなる -->
</div>
推奨アプローチ:
importantは最終手段として使用する- まず、レイヤや
cn関数での解決を試みる - 使用する場合は、コメントで理由を明記する
 
typescript// サードパーティコンポーネントのスタイルを上書きするため important を使用
<ThirdPartyModal className='!bg-white'>
  モーダルコンテンツ
</ThirdPartyModal>
具体例
実例 1: shadcn/ui の Button をカスタマイズする
shadcn/ui のButtonコンポーネントをカスタマイズする実践的な例を見ていきます。
元の Button コンポーネント
typescript// components/ui/button.tsx (shadcn/ui 標準)
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',
        outline:
          'border border-input bg-background hover:bg-accent',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
      },
    },
  }
);
カスタムバリアントの追加
新しいgradientバリアントを追加します。
typescript// components/ui/button.tsx (カスタマイズ版)
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',
        outline:
          'border border-input bg-background hover:bg-accent',
        // 新規追加: グラデーションバリアント
        gradient:
          'bg-gradient-to-r from-purple-500 to-pink-500 text-white',
      },
      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',
    },
  }
);
使用例
typescript// pages/index.tsx
import { Button } from '@/components/ui/button';
export default function Home() {
  return (
    <div className='space-y-4 p-8'>
      {/* 標準バリアント */}
      <Button variant='default'>デフォルトボタン</Button>
      {/* カスタムバリアント */}
      <Button variant='gradient'>
        グラデーションボタン
      </Button>
      {/* className で個別カスタマイズ */}
      <Button
        variant='gradient'
        className='shadow-lg hover:shadow-xl'
      >
        影付きグラデーションボタン
      </Button>
    </div>
  );
}
以下の図は、Button コンポーネントのスタイル適用フローを示しています。
mermaidflowchart TD
  A["Button コンポーネント呼び出し"] --> B["cva で variant を評価"]
  B --> C["ベースクラスを適用"]
  C --> D["variant クラスを追加"]
  D --> E["size クラスを追加"]
  E --> F["cn 関数で className をマージ"]
  F --> G["最終的な className"]
  style B fill:#e1bee7
  style F fill:#c5e1a5
  style G fill:#b3e5fc
実例 2: カスタムコンポーネントでレイヤを活用
独自のカードコンポーネントを作成し、レイヤシステムを活用します。
カスタム CSS の定義
css/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
  /* ベーススタイル */
  .feature-card {
    @apply rounded-xl p-6 transition-all duration-300;
    @apply border border-gray-200 bg-white;
  }
  /* ホバー時のスタイル */
  .feature-card:hover {
    @apply shadow-lg border-gray-300;
    @apply transform -translate-y-1;
  }
}
React コンポーネントの実装
typescript// components/FeatureCard.tsx
import { cn } from '@/lib/utils';
import { ReactNode } from 'react';
interface FeatureCardProps {
  title: string;
  description: string;
  icon?: ReactNode;
  className?: string;
}
export function FeatureCard({
  title,
  description,
  icon,
  className,
}: FeatureCardProps) {
  return (
    <div className={cn('feature-card', className)}>
      {/* アイコン表示エリア */}
      {icon && (
        <div className='mb-4 text-blue-600'>{icon}</div>
      )}
      {/* タイトル */}
      <h3 className='text-xl font-bold mb-2 text-gray-900'>
        {title}
      </h3>
      {/* 説明文 */}
      <p className='text-gray-600 leading-relaxed'>
        {description}
      </p>
    </div>
  );
}
使用例とカスタマイズ
typescript// pages/features.tsx
import { FeatureCard } from '@/components/FeatureCard';
import { Zap, Shield, Rocket } from 'lucide-react';
export default function FeaturesPage() {
  return (
    <div className='grid grid-cols-1 md:grid-cols-3 gap-6 p-8'>
      {/* 標準スタイル */}
      <FeatureCard
        icon={<Zap className='w-8 h-8' />}
        title='高速'
        description='最新の技術スタックで構築されており、驚くほど高速に動作します。'
      />
      {/* 背景色をカスタマイズ */}
      <FeatureCard
        icon={<Shield className='w-8 h-8' />}
        title='安全'
        description='エンタープライズグレードのセキュリティを提供します。'
        className='bg-blue-50 border-blue-200'
      />
      {/* 複数のカスタマイズを適用 */}
      <FeatureCard
        icon={<Rocket className='w-8 h-8' />}
        title='スケーラブル'
        description='ビジネスの成長に合わせて柔軟にスケールできます。'
        className='bg-gradient-to-br from-purple-50 to-pink-50 border-purple-200'
      />
    </div>
  );
}
この実装では、feature-cardクラスがcomponentsレイヤに定義されているため、classNameプロパティで渡されるユーティリティクラスが優先されます。
実例 3: フォームコンポーネントの競合解決
shadcn/ui のInputコンポーネントをベースに、バリデーションエラー表示を含むフォームを実装します。
カスタム Input の実装
typescript// components/ui/input.tsx (shadcn/ui ベース)
import { cn } from '@/lib/utils';
import { forwardRef, InputHTMLAttributes } from 'react';
export interface InputProps
  extends InputHTMLAttributes<HTMLInputElement> {
  error?: boolean;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ className, error, type, ...props }, ref) => {
    return (
      <input
        type={type}
        className={cn(
          // ベーススタイル
          'flex h-10 w-full rounded-md border px-3 py-2',
          'bg-background text-sm ring-offset-background',
          'file:border-0 file:bg-transparent file:text-sm file:font-medium',
          // フォーカス時のスタイル
          'focus-visible:outline-none focus-visible:ring-2',
          'focus-visible:ring-ring focus-visible:ring-offset-2',
          // 無効化時のスタイル
          'disabled:cursor-not-allowed disabled:opacity-50',
          // エラー状態の条件分岐
          {
            'border-input': !error,
            'border-red-500 focus-visible:ring-red-500':
              error,
          },
          // 外部からのカスタマイズを最後に適用
          className
        )}
        ref={ref}
        {...props}
      />
    );
  }
);
Input.displayName = 'Input';
export { Input };
フォームバリデーションコンポーネント
typescript// components/FormField.tsx
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
interface FormFieldProps {
  label: string;
  error?: string;
  className?: string;
}
export function FormField({
  label,
  error,
  className,
}: FormFieldProps) {
  return (
    <div className={cn('space-y-2', className)}>
      {/* ラベル */}
      <label className='text-sm font-medium text-gray-700'>
        {label}
      </label>
      {/* 入力フィールド */}
      <Input
        error={!!error}
        className={cn({
          // エラー時に背景色を変更
          'bg-red-50': !!error,
        })}
      />
      {/* エラーメッセージ */}
      {error && (
        <p className='text-sm text-red-600'>{error}</p>
      )}
    </div>
  );
}
実際のフォームでの使用
typescript// pages/contact.tsx
import { FormField } from '@/components/FormField';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
export default function ContactPage() {
  const [errors, setErrors] = useState<
    Record<string, string>
  >({});
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // バリデーションロジック
    const newErrors: Record<string, string> = {};
    if (!email) {
      newErrors.email = 'メールアドレスを入力してください';
    }
    setErrors(newErrors);
  };
  return (
    <form
      onSubmit={handleSubmit}
      className='max-w-md mx-auto p-8 space-y-6'
    >
      <h1 className='text-2xl font-bold'>お問い合わせ</h1>
      {/* 正常な入力フィールド */}
      <FormField label='お名前' error={errors.name} />
      {/* エラー状態の入力フィールド */}
      <FormField
        label='メールアドレス'
        error={errors.email}
      />
      {/* カスタマイズされたフィールド */}
      <FormField label='電話番号' className='mb-8' />
      <Button type='submit' className='w-full'>
        送信する
      </Button>
    </form>
  );
}
この実装では、以下のような優先順位でスタイルが適用されます。
mermaidflowchart TD
  A["Input ベースクラス"] --> B["error プロパティ評価"]
  B --> C{"エラーあり?"}
  C -->|はい| D["border-red-500 適用"]
  C -->|いいえ| E["border-input 適用"]
  D --> F["FormField の className"]
  E --> F
  F --> G["bg-red-50 条件付き適用"]
  G --> H["最終スタイル"]
  style D fill:#ffcdd2
  style G fill:#ffcdd2
  style H fill:#b3e5fc
実例 4: ダークモード対応とクラス競合
ダークモード対応時のクラス競合と、その解決方法を見ていきます。
Tailwind 設定でダークモードを有効化
javascript// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  // クラスベースのダークモード
  darkMode: 'class',
  content: [
    './pages/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};
ダークモード対応コンポーネント
typescript// components/ThemeCard.tsx
import { cn } from '@/lib/utils';
import { ReactNode } from 'react';
interface ThemeCardProps {
  children: ReactNode;
  className?: string;
}
export function ThemeCard({
  children,
  className,
}: ThemeCardProps) {
  return (
    <div
      className={cn(
        // ライトモードのスタイル
        'rounded-lg p-6 border',
        'bg-white border-gray-200 text-gray-900',
        // ダークモードのスタイル
        'dark:bg-gray-800 dark:border-gray-700 dark:text-white',
        // ホバー時のスタイル
        'hover:shadow-lg transition-shadow',
        'dark:hover:shadow-gray-900/50',
        // 外部からのカスタマイズ
        className
      )}
    >
      {children}
    </div>
  );
}
テーマ切り替え機能の実装
typescript// components/ThemeToggle.tsx
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
  const [isDark, setIsDark] = useState(false);
  useEffect(() => {
    // 初期テーマを読み込み
    const theme = localStorage.getItem('theme');
    setIsDark(theme === 'dark');
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    }
  }, []);
  const toggleTheme = () => {
    const newTheme = !isDark;
    setIsDark(newTheme);
    if (newTheme) {
      document.documentElement.classList.add('dark');
      localStorage.setItem('theme', 'dark');
    } else {
      document.documentElement.classList.remove('dark');
      localStorage.setItem('theme', 'light');
    }
  };
  return (
    <Button
      variant='outline'
      size='sm'
      onClick={toggleTheme}
      className={cn(
        'w-10 h-10 p-0',
        // ダークモード時の特別なスタイル
        'dark:hover:bg-gray-700'
      )}
    >
      {isDark ? (
        <Sun className='w-5 h-5' />
      ) : (
        <Moon className='w-5 h-5' />
      )}
    </Button>
  );
}
ダークモード対応ページ
typescript// pages/dashboard.tsx
import { ThemeCard } from '@/components/ThemeCard';
import { ThemeToggle } from '@/components/ThemeToggle';
export default function DashboardPage() {
  return (
    <div className='min-h-screen bg-gray-50 dark:bg-gray-900 p-8'>
      {/* ヘッダー */}
      <header className='flex justify-between items-center mb-8'>
        <h1 className='text-3xl font-bold text-gray-900 dark:text-white'>
          ダッシュボード
        </h1>
        <ThemeToggle />
      </header>
      {/* カードグリッド */}
      <div className='grid grid-cols-1 md:grid-cols-3 gap-6'>
        {/* 標準カード */}
        <ThemeCard>
          <h2 className='text-xl font-semibold mb-2'>
            統計情報
          </h2>
          <p className='text-gray-600 dark:text-gray-400'>
            今月のアクティブユーザー数
          </p>
          <p className='text-4xl font-bold mt-4'>1,234</p>
        </ThemeCard>
        {/* カスタマイズされたカード */}
        <ThemeCard className='bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'>
          <h2 className='text-xl font-semibold mb-2 text-blue-900 dark:text-blue-100'>
            売上
          </h2>
          <p className='text-blue-600 dark:text-blue-400'>
            今月の総売上
          </p>
          <p className='text-4xl font-bold mt-4 text-blue-900 dark:text-blue-100'>
            ¥567,890
          </p>
        </ThemeCard>
        {/* グラデーションカード */}
        <ThemeCard className='bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20'>
          <h2 className='text-xl font-semibold mb-2'>
            新規登録
          </h2>
          <p className='text-gray-600 dark:text-gray-400'>
            今週の新規ユーザー
          </p>
          <p className='text-4xl font-bold mt-4'>89</p>
        </ThemeCard>
      </div>
    </div>
  );
}
この実装により、ライトモードとダークモードの両方で適切にスタイルが適用され、クラスの競合もcn関数によって適切に解決されます。
まとめ
shadcn/ui と Tailwind CSS を使用する際のクラス競合は、適切な知識と手法を身につけることで効果的に解決できます。この記事で解説した内容を振り返ってみましょう。
重要なポイント
| # | ポイント | 解決策 | 
|---|---|---|
| 1 | cn 関数の活用 | tailwind-mergeによる自動マージを活用 | 
| 2 | レイヤシステムの理解 | @layerで優先順位を制御 | 
| 3 | @apply の適切な使用 | componentsレイヤで基本スタイルを定義 | 
| 4 | クラス記述順序の最適化 | 外部のclassNameを最後に配置 | 
| 5 | important 修飾子の戦略的使用 | 最終手段として限定的に使用 | 
クラス競合解決のフローチャート
問題に直面した際は、以下のフローチャートを参考にしてください。
mermaidflowchart TD
  A["クラスが適用されない"] --> B{"cn 関数を使用?"}
  B -->|はい| C{"className の位置は?"}
  B -->|いいえ| D["cn 関数を導入"]
  C -->|最後| E{"@layer を確認"}
  C -->|途中| F["className を最後に移動"]
  E -->|components| G["ユーティリティで上書き可能"]
  E -->|utilities| H["レイヤを components に変更"]
  G --> I{"それでも解決しない?"}
  H --> I
  F --> I
  D --> I
  I -->|はい| J["important 修飾子を検討"]
  I -->|いいえ| K["解決!"]
  J --> K
  style K fill:#c5e1a5
ベストプラクティス
最後に、日々の開発で意識すべきベストプラクティスをまとめます。
設計段階での考慮事項:
- コンポーネントの基本スタイルは
componentsレイヤで定義しましょう - カスタマイズ可能なプロパティは、あえてベーススタイルに含めないようにします
 classNameプロパティを必ず受け取れるようにし、cn関数の最後に配置します
実装段階での注意点:
tailwind-mergeを信頼し、同じプロパティへの複数クラス指定を恐れないでください@applyは再利用性の高い共通スタイルにのみ使用しましょうimportant修飾子は、本当に必要な場合のみ使用し、コメントで理由を明記します
メンテナンス性の向上:
- クラスの記述順序を統一し、チーム内でルールを共有しましょう
 - 複雑なスタイルロジックは、
cvaを使って明示的にバリアントとして定義します - ドキュメントコメントで、カスタマイズ可能なポイントを明示しましょう
 
これらの手法を実践することで、shadcn/ui と Tailwind CSS の組み合わせを最大限に活用し、保守性の高いコードベースを構築できます。クラス競合に悩まされることなく、快適な開発体験を楽しんでください。
関連リンク
articleshadcn/ui で Tailwind クラスが競合する時の対処法:優先度・レイヤ・@apply の整理
articleshadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方
articleshadcn/ui で Command Palette を実装:検索・履歴・キーボードショートカット対応
articleshadcn/ui の asChild/Slot を極める:継承可能な API 設計と責務分離
articleshadcn/ui カラートークン早見表:ブランドカラー最適化&明暗コントラスト基準
articleshadcn/ui を Monorepo(Turborepo/pnpm)に導入するベストプラクティス
articleWebSocket が「200 OK で Upgrade されない」原因と対処:プロキシ・ヘッダー・TLS の落とし穴
articleWebRTC 本番運用の SLO 設計:接続成功率・初画出し時間・通話継続率の基準値
articleAstro のレンダリング戦略を一望:MPA× 部分ハイドレーションの強みを図解解説
articleWebLLM が読み込めない時の原因と解決策:CORS・MIME・パス問題を総点検
articleVitest ESM/CJS 混在で `Cannot use import statement outside a module` が出る技術対処集
articleテスト環境比較:Vitest vs Jest vs Playwright CT ― Vite プロジェクトの最適解
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来