T-CREATOR

shadcn/ui で Tailwind クラスが競合する時の対処法:優先度・レイヤ・@apply の整理

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コンポーネント内部スタイル各コンポーネントに基本スタイルが含まれる
2variants による条件分岐class-variance-authority でスタイルを管理
3cn 関数による結合clsxtailwind-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 つのライブラリを組み合わせています。

#ライブラリ役割
1clsx条件付きクラス名の結合
2tailwind-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 を使用する際のクラス競合は、適切な知識と手法を身につけることで効果的に解決できます。この記事で解説した内容を振り返ってみましょう。

重要なポイント

#ポイント解決策
1cn 関数の活用tailwind-mergeによる自動マージを活用
2レイヤシステムの理解@layerで優先順位を制御
3@apply の適切な使用componentsレイヤで基本スタイルを定義
4クラス記述順序の最適化外部のclassNameを最後に配置
5important 修飾子の戦略的使用最終手段として限定的に使用

クラス競合解決のフローチャート

問題に直面した際は、以下のフローチャートを参考にしてください。

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 の組み合わせを最大限に活用し、保守性の高いコードベースを構築できます。クラス競合に悩まされることなく、快適な開発体験を楽しんでください。

関連リンク