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 のバンドルサイズ影響を検証:Tree Shaking・Code Split の実測データ
articleshadcn/ui × RSC 時代のフロント設計:Server/Client の最適境界を見極める指針
articleshadcn/ui のテンプレート差分を追従する運用:更新検知・差分マージ・回帰防止
articleshadcn/ui で Tailwind クラスが競合する時の対処法:優先度・レイヤ・@apply の整理
articleshadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方
articleshadcn/ui で Command Palette を実装:検索・履歴・キーボードショートカット対応
articleZod 合成パターン早見表:`object/array/tuple/record/map/set/intersection` 実例集
articleバックアップ戦略の決定版:WordPress の世代管理/災害復旧の型
articleYarn 運用ベストプラクティス:lockfile 厳格化・frozen-lockfile・Bot 更新方針
articleWebSocket のペイロード比較:JSON・MessagePack・Protobuf の速度とコスト検証
articleWeb Components イベント設計チート:`CustomEvent`/`composed`/`bubbles` 実例集
articleWebRTC SDP 用語チートシート:m=・a=・bundle・rtcp-mux を 10 分で総復習
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 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来