最近在@sylwia-lask 發表的這篇文章《 2026 年學習 CSS 是否浪費時間? 》真的讓我感觸很深,尤其是關於可存取性會把你直接拉回原始 CSS 的那部分。
最近有了 Tailwind 和 shadcn,大多數樣式設定都…非常有效。快速操作,調整一兩個類,就搞定了。
然後,影子DOM出現了。
突然間,那些「理當正常運作」的功能都失效了。覆蓋設定不再生效,樣式變得難以駕馭,所有那些抽象概念都顯得比預期的要單薄得多。
這不是對 Tailwind 或 shadcn 的抱怨……只是想提醒大家,掌握 CSS 知識仍然能在事情出錯時幫助你。
所以,我的想法是這樣的:用 React 建立美觀、可重複使用的 Web 元件,用 Tailwind CSS 設定樣式,用 shadcn/ui 打造精緻的 UI 元件,最後用 Shadow DOM 封裝起來,實現完美封裝。聽起來很棒,對吧?
嗯……並非如此。事實證明,這三種技術並不能很好地兼容。以下是我們付出慘痛代價後得出的結論。
簡而言之: Shadow DOM + Tailwind + shadcn/ui = 痛苦。請根據實際需求謹慎選擇,而非理論上的理想方案。有時,「不完美」的方案反而是正確的。
我們正在建造:
React 元件封裝成 Web 元件
使用Tailwind CSS進行樣式設計
使用shadcn/ui元件建立使用者介面
使用Shadow DOM進行樣式封裝
我們來談談哪裡出了問題。
Tailwind CSS 的設計概念很簡單:在全域樣式表中使用實用類別。你只需要引入一個 CSS 文件,然後頁面上的每個元素就可以使用諸如bg-blue-500或flex justify-center之類的類別。
Shadow DOM 的設計概念恰恰相反:完全隔離。 Shadow DOM 內部的樣式不會洩漏出去,外部的樣式也不會洩漏進來。這對於封裝性來說非常棒,但對於 Tailwind 框架來說卻很糟糕。
事情經過是這樣的:
// Your React component with Tailwind classes
export const MyCard = () => {
return (
<div className="max-w-2xl w-full p-4 bg-white rounded-lg shadow-md">
<h1 className="text-2xl font-bold text-gray-900">Hello World</h1>
<p className="text-gray-600 mt-2">This should look nice...</p>
</div>
);
};
// Wrap it as a web component with Shadow DOM
const MyCardWC = r2wc(MyCard, {
shadow: 'open' // Enable Shadow DOM
});
customElements.define('my-card', MyCardWC);
結果:元件渲染成功,但看起來完全錯亂。沒有內邊距,沒有背景色,沒有圓角。什麼都沒有。所有 Tailwind 類別都被忽略了,因為全域 Tailwind 樣式表無法穿透 Shadow DOM 邊界。
您必須將 Tailwind CSS 直接匯入到每個元件中:
// styles.css
@tailwind base;
@tailwind components;
@tailwind utilities;
// Component file
import './styles.css'; // Import for every component
const MyCardWC = r2wc(MyCard, {
shadow: 'open'
});
這種方法可行,但需要付出代價:
捆綁包尺寸爆炸
每個 Web 元件都包含自己完整的 Tailwind CSS 副本。如果頁面上有 5 個元件,那麼 Tailwind CSS 就會載入 5 次。這意味著 5 份完全相同的 CSS 被載入。
不使用瀏覽器快取
由於每個元件都有自己打包的樣式,因此無法利用瀏覽器快取來共享 CSS。每個元件下載包都包含相同的 Tailwind 工具。
建構複雜性
你的建置工具需要分別處理每個元件的 CSS 導入,這使得你的 webpack/vite 配置更加複雜。
實數:
單一 Tailwind CSS 檔案:約 50-100KB(壓縮後)
包含 3 個 Web 元件:150-300KB
包含 10 個 Web 元件:500KB-1MB
是的,不太好。
shadcn/ui 是基於 Radix UI 的基本元件建構的,這些元件非常出色。但它們有一個與 Shadow DOM 不相容的怪癖:門戶(portals) 。
像是對話框、下拉式選單、彈出框和工具提示這樣的元件都使用 React Portal 來渲染其內容,使其位於常規元件樹之外,通常是透過追加到document.body來實現的。這對於 z-index 管理以及避免溢出問題來說很巧妙,但對於 Shadow DOM 來說卻是一場災難。
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@your-ui/components';
export const FAQ = () => {
return (
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>What is this?</AccordionTrigger>
<AccordionContent>
This is an accordion that actually works with Shadow DOM!
</AccordionContent>
</AccordionItem>
</Accordion>
);
};
const FAQWC = r2wc(FAQ, { shadow: 'open' });
如何運作:手風琴元件會將所有內容直接渲染到元件內部。沒有傳送門,也沒有內容瞬移。所有 HTML 都保留在元件樹內,因此 Shadow DOM 可以對其進行樣式設定。
import { Dialog, DialogTrigger, DialogContent } from '@your-ui/components';
export const MyDialog = () => {
return (
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<h2>This won't be styled properly!</h2>
<p>The content is outside Shadow DOM now.</p>
</DialogContent>
</Dialog>
);
};
const MyDialogWC = r2wc(MyDialog, { shadow: 'open' });
它為什麼會壞:
DialogContent被傳送到document.body
它現在位於你的 Shadow DOM 之外。
你所有的 Tailwind 類別(在 Shadow DOM 內部)都無法存取它。
對話框可以渲染,但看起來完全沒有樣式。
你所看到的:
無背景疊加
對話框沒有樣式
文字未居中
按鈕看起來像是純 HTML 程式碼。
Z軸索引問題(可能渲染在其他元素後面)
你必須二選一:影子DOM或傳送門。兩者不可兼得。
方案 A:對包含大量 portal 元件停用 Shadow DOM
// No Shadow DOM = portals work, but no encapsulation
const MyDialogWC = r2wc(MyDialog, {
shadow: null
});
現在你需要全域管理樣式,並處理潛在的類別名稱衝突。
選項 B:僅使用非門戶元件
// ✅ Safe to use with Shadow DOM
import {
Accordion,
Card,
Badge,
Button,
Tabs,
Progress
} from '@your-ui/components';
// ❌ Don't use with Shadow DOM (they use portals)
import {
Dialog,
Popover,
Tooltip,
DropdownMenu,
Sheet,
AlertDialog
} from '@your-ui/components';
這會大大限制你的使用者介面工具包。
shadcn/ui 使用 CVA 來處理元件變體——不同的尺寸、顏色和狀態。這會動態產生 Tailwind 類別:
import { cva } from 'class-variance-authority';
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-red-500 text-white hover:bg-red-600",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
// Button component
export const Button = ({ variant, size, children }) => {
return (
<button className={buttonVariants({ variant, size })}>
{children}
</button>
);
};
所有這些動態產生的類別都需要存在於 Shadow DOM 的樣式表中。但是 Tailwind 的 JIT(即時)編譯器只會在建置時將檔案中找到的類別包含在內。
當 CVA 在執行時期動態合併類別時,Tailwind 可能沒有將它們包含在建置中,導致樣式缺失。
// tailwind.config.js
module.exports = {
content: [
'./src/**/*.{ts,tsx}',
// CRITICAL: Include your UI library
'./node_modules/@your-ui-lib/**/*.{ts,tsx}',
],
// Force include commonly used variant classes
safelist: [
// Primary variants
'bg-primary',
'text-primary-foreground',
'hover:bg-primary/90',
// Destructive variants
'bg-red-500',
'bg-red-600',
'hover:bg-red-600',
// Sizes
'h-9',
'h-10',
'h-11',
'px-3',
'px-4',
'px-8',
// Add every possible variant combination...
],
};
安全清單的問題:
您需要手動列出所有可能的課程組合
容易錯過課程(導致視覺錯誤)
增加 CSS 包大小(違背了 JIT 的目的)
UI庫更改時需要更新
shadcn/ui 使用 CSS 自訂屬性(變數)進行主題化:
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
/* ... many more */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... dark theme values */
}
然後在你的 Tailwind 配置中:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
},
},
},
};
CSS 自訂屬性會透過 DOM 樹繼承,但 Shadow DOM 建立了一個邊界。在外部定義的變數不會自動流入。
// This won't work as expected
export const ThemedCard = () => {
return (
<div className="bg-background text-foreground p-4">
<h2 className="text-primary font-bold">Title</h2>
<p>Content here...</p>
</div>
);
};
const ThemedCardWC = r2wc(ThemedCard, { shadow: 'open' });
結果:您的元件無法存取--background 、 --foreground或--primary變數。所有主題顏色將回退到預設值或完全失效。
您需要在 Shadow DOM 中重新聲明 CSS 變數:
// styles.css (imported by your component)
:host {
/* Re-declare all theme variables */
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--primary: 221.2 83.2% 53.3%;
/* ... all other variables */
}
@tailwind base;
@tailwind components;
@tailwind utilities;
這種方法存在的問題:
主題變數到處重複出現。
深色模式需要額外的工作(不能簡單地在document.body上切換一個類別)。
更新主題意味著更新多個文件
沒有單一的真理來源
讓我們來看看這在實踐中意味著什麼。假設您正在使用以下元件建立儀表板:
// 1. A stats card
const StatsCard = () => (
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold text-gray-900">Total Users</h3>
<p className="text-3xl font-bold text-primary mt-2">1,234</p>
<p className="text-sm text-gray-600 mt-1">+12% from last month</p>
</div>
);
// 2. A data table (with dropdown menu)
const DataTable = () => (
<div className="bg-white rounded-lg shadow">
<Table>
{/* table content */}
</Table>
<DropdownMenu>
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
// 3. A settings dialog
const SettingsDialog = () => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Settings</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
{/* form content */}
</DialogContent>
</Dialog>
);
統計卡: ✅ 完美執行
沒有門戶
所有款式均為獨立式
捆綁包:+80KB(Tailwind CSS)
資料表: ⚠️ 部分損壞
桌子看起來不錯
下拉式選單顯示異常(在 Shadow DOM 之外渲染的 Portal 沒有樣式)
捆綁包:+80KB(Tailwind CSS)
設定對話框: ❌ 完全損壞
按鈕看起來沒問題
對話方塊內容顯示,但完全沒有樣式。
背景布幕可能無法正常運作
捆綁包:+80KB(Tailwind CSS)
總捆綁成本: 3 個元件共 240KB 重複 CSS
一切正常: ✅
所有入口網站均運作正常。
下拉式選單和對話框樣式正確
檔案大小:80KB(單一 Tailwind CSS 檔案)
但:
無樣式封裝
潛在的類別名稱衝突
全域樣式可以傳入/傳出
需要注意具體細節。
良好應用案例:
// Simple, self-contained components
- Cards
- Badges
- Progress bars
- Accordions
- Tabs
- Buttons (non-portal variants)
這些元件:
不要使用傳送門
無需在其邊界之外進行複雜的交互
受益於風格隔離
跳過它:
// Components with portals or complex interactions
- Dialogs
- Popovers
- Tooltips
- Dropdown menus
- Context menus
- Toast notifications
// Option 1: Selective Shadow DOM
// Use Shadow DOM only for truly isolated components
const CardWC = r2wc(Card, { shadow: 'open' });
const BadgeWC = r2wc(Badge, { shadow: 'open' });
// Skip Shadow DOM for interactive components
const DialogWC = r2wc(Dialog, { shadow: null });
const DropdownWC = r2wc(Dropdown, { shadow: null });
// Option 2: No Shadow DOM, CSS Modules
// Use CSS Modules for scoping instead
import styles from './Card.module.css';
const Card = () => (
<div className={styles.card}>
{/* Use scoped CSS instead of Shadow DOM */}
</div>
);
// Option 3: Scoped Tailwind (advanced)
// Generate component-specific Tailwind with prefixes
// tailwind.config.js
module.exports = {
prefix: 'card-', // All classes become card-bg-white, card-p-4, etc.
content: ['./src/Card.tsx'],
};
Shadow DOM、Tailwind CSS 和 shadcn/ui 本身都是非常優秀的技巧。但是,如果把它們放在一起呢?它們就會互相衝突。
Shadow DOM 需要:完全隔離
Tailwind 需要:全域實用程式類
shadcn/ui 需要:用於正確管理 z-index 的門戶
只能選兩個。三個不可能同時完美運作。
打包大小很重要-在元件間重複使用 Tailwind CSS 會迅速增加成本。
Portal 會破壞 Shadow DOM——大多數現代 UI 函式庫都大量使用 Portal。
CSS變數不會跨越邊界——主題化變得複雜
CVA 需要特殊處理- 動態類別需要安全清單配置
總是會有權衡取捨——封裝性、套件大小和功能性之間需要權衡。
我們最終採用了混合方法:
在我們的用例中完全跳過 Shadow DOM。
使用 TypeScript 和元件包裝器來實現類型安全
接受全域樣式表
讓 shadcn/ui 入口網站按預期執行
專注於清晰的元件 API,而不是 Shadow DOM 封裝。
它完美嗎?不。但它有效,而這比建築的純粹性更重要。
原文出處:https://dev.to/ujja/i-know-this-will-upset-some-devs-but-tailwind-shadcnui-shadow-dom-pain-44l7