Tailwind, Radix & shadcn/ui · Part 3 — Design Tokens & Theming with @theme
The heart of Tailwind v4: define your own colors, fonts, spacing and radius as design tokens with @theme — each token becomes a utility automatically. Plus the v4 way to do dark mode. With a live token studio.
Ở v3 bạn cấu hình Tailwind trong một file JavaScript (tailwind.config.js). Ở v4 cấu hình nằm thẳng trong CSS của bạn, biểu diễn dưới dạng design token với directive @theme. Đây là khái niệm v4 quan trọng nhất — nó vừa định nghĩa hệ thống thiết kế, vừa sinh ra các utility bạn gõ hằng ngày, và là cầu nối trực tiếp tới cách shadcn/ui theme component (Phần 10). Hiểu kỹ phần này thì 80% phần còn lại của series trở nên hiển nhiên.
Bài này không chỉ nói về cú pháp
@theme rất dễ học nếu chỉ nhìn cú pháp: khai --color-brand, dùng bg-brand. Nhưng phần làm một codebase sống khỏe không nằm ở việc gõ đúng prefix, mà nằm ở thiết kế hệ token.
Một hệ token trưởng thành thường có ba tầng:
| Tầng token | Đặt tên theo gì | Ví dụ | Khi nào dùng |
|---|---|---|---|
| Primitive | Giá trị gốc / palette | --color-indigo-600, --radius-2 | Lưu bảng màu, thang radius, spacing nền |
| Semantic | Vai trò trong UI | --color-primary, --color-background, --color-muted-foreground | Dùng trực tiếp trong component/app |
| Component | Vai trò riêng của component | --color-button-primary-bg, --radius-dialog | Khi component lớn cần kiểm soát riêng |
Nguyên tắc quan trọng: component nên phụ thuộc vào semantic token, không phụ thuộc thẳng vào primitive token. Nếu bạn viết bg-indigo-600 khắp app, khi brand đổi từ indigo sang emerald bạn phải sửa hàng loạt file. Nếu component dùng bg-primary, bạn chỉ đổi --color-primary.
Tailwind vẫn cho bạn dùng palette mặc định vì nó cực tiện khi prototype. Nhưng khi bước vào sản phẩm thật, hãy dần kéo các quyết định lặp lại lên semantic token. Đó là khác biệt giữa “UI chạy được” và “design system có thể đổi mà không đau”.
1. Design token là gì — và tại sao cần nó
Một design token là một quyết định thiết kế có tên: “màu brand là #6366f1”, “bo góc card là 12px”, “font tiêu đề là Cal Sans”. Thay vì rải con số #6366f1 khắp 40 file, bạn đặt nó một chỗ rồi tham chiếu bằng tên. Đó là nguyên tắc một nguồn sự thật duy nhất (single source of truth): đổi token → mọi nơi dùng nó tự cập nhật.
Không có token, hệ thống thiết kế “trôi dạt” theo thời gian:
Tuần 1: brand = #6366f1 (designer chọn)
Tuần 6: ai đó gõ #6365f0 (lệch 1 sắc, copy nhầm)
Tuần 12: ba sắc brand khác nhau cùng tồn tại trong app
→ không ai biết cái nào "đúng", không ai dám sửa
Token chặn đứng kiểu trôi dạt đó: chỉ có một --color-brand, ai cũng trỏ về nó.
Siêu năng lực riêng của Tailwind v4 nằm ở chỗ: mọi token bạn khai trong @theme không chỉ là một biến CSS — nó còn tự động sinh ra utility class tương ứng. Một dòng khai báo, hai thứ xuất hiện. Ta sẽ mổ xẻ cơ chế này ngay.
Vì sao “token” chứ không phải “biến”? Mọi token là biến CSS, nhưng không phải biến nào cũng là token. Token là biến mang ý nghĩa thiết kế có chủ đích trong hệ thống (
--color-brand,--radius-card), được chuẩn hóa để cả team dùng chung — khác với một biến tạm--xbạn đặt cho một phép tính lẻ.
2. Directive @theme — cú pháp và “hai-trong-một”
@theme là một khối CSS đặc biệt bạn đặt sau dòng @import "tailwindcss". Bên trong, bạn khai báo token như khai biến CSS thường — nhưng Tailwind đọc chúng lúc build để sinh utility.
/* src/index.css */
@import "tailwindcss";
@theme {
--color-brand: #6366f1;
--color-brand-foreground: #ffffff;
--radius-card: 12px;
--font-display: "Cal Sans", sans-serif;
--spacing-gutter: 1.25rem;
--shadow-card: 0 4px 12px rgb(0 0 0 / 0.08);
}
Từ sáu dòng đó bạn lập tức có utility dùng được khắp nơi — không cần khai báo thêm gì:
<button class="bg-brand text-brand-foreground rounded-card font-display p-gutter shadow-card">
Upgrade
</button>
Điểm mấu chốt cần khắc cốt ghi tâm: mỗi token làm đúng hai việc cùng lúc. Lấy --color-brand: #6366f1 làm ví dụ:
| Việc thứ nhất — biến CSS | Việc thứ hai — utility class |
|---|---|
Tailwind phát --color-brand: #6366f1 vào :root | Tailwind sinh các class bg-brand, text-brand, border-brand, ring-brand, fill-brand… |
Đọc được lúc runtime: var(--color-brand) | Mỗi class dùng var(--color-brand) ở dưới capô |
Override được trong scope (.dark { … }) | Khi bạn override biến, mọi utility brand đổi theo |
Đây chính là lý do dark mode và theming trong v4 “gọn đến khó tin” (mục 8): bạn không sửa utility, bạn chỉ đổi giá trị biến mà utility trỏ tới.
Đổi một token và toàn bộ UI đi theo — thử trực tiếp trong token studio (kéo màu, radius, font → xem khối @theme sinh ra và component cập nhật ngay):
3. Bảng ánh xạ namespace — token prefix → utility
Cách đặt tên token không tùy ý. Phần đầu của tên (namespace) quyết định Tailwind sinh ra nhóm utility nào. Đây là bảng tra đầy đủ — học thuộc cột “prefix” là đủ để tự suy ra mọi thứ:
| Token namespace | Sinh ra utility | Ví dụ token → class | Tham chiếu CSS |
|---|---|---|---|
--color-* | bg-*, text-*, border-*, ring-*, fill-*, stroke-*, outline-*, accent-*, decoration-*, shadow-*/color, from-*/via-*/to-* | --color-brand → bg-brand, text-brand, border-brand, ring-brand | color, background-color, … |
--font-* | font-* (font-family) | --font-display → font-display | font-family |
--text-* | text-* (font-size) | --text-hero → text-hero | font-size (+ line-height kèm theo) |
--font-weight-* | font-* (weight) | --font-weight-heavy → font-heavy | font-weight |
--tracking-* | tracking-* | --tracking-tight → tracking-tight | letter-spacing |
--leading-* | leading-* | --leading-snug → leading-snug | line-height |
--spacing-* | p-*, m-*, w-*, h-*, gap-*, inset-*, space-x-*, size-*, min-w-*… | --spacing-gutter → p-gutter, mt-gutter, gap-gutter, w-gutter | padding, margin, width… |
--radius-* | rounded-* | --radius-card → rounded-card | border-radius |
--shadow-* | shadow-* | --shadow-card → shadow-card | box-shadow |
--inset-shadow-* | inset-shadow-* | --inset-shadow-sm → inset-shadow-sm | inner box-shadow |
--blur-* | blur-* | --blur-soft → blur-soft | filter: blur() |
--breakpoint-* | variant responsive sm:, md:… | --breakpoint-tablet → tablet:flex | @media (min-width: …) |
--container-* | max-w-* + container query @* | --container-prose → max-w-prose | max-width |
--aspect-* | aspect-* | --aspect-card → aspect-card | aspect-ratio |
--ease-* | ease-* | --ease-snappy → ease-snappy | transition-timing-function |
--animate-* | animate-* | --animate-wiggle → animate-wiggle | animation |
Quy luật vàng: prefix của token phải khớp namespace của utility bạn muốn. Muốn
rounded-xl-cardthì token phải tên--radius-…, không phải--rounded-…. Đặt sai namespace là gotcha số một (mục 13).
Ví dụ thấm bảng trên: một token spacing duy nhất phục vụ cả chục họ utility.
@theme {
--spacing-gutter: 1.25rem; /* 20px */
}
<!-- cùng một token, dùng cho padding, margin, gap, width, inset… -->
<div class="p-gutter mt-gutter gap-gutter w-gutter -left-gutter">…</div>
4. Dưới capô — một token sinh ra CSS gì
Để gỡ bug tự tin, bạn cần thấy chính xác Tailwind biến @theme thành gì. Luồng như sau:
@theme { --color-brand: #6366f1; --radius-card: 12px; }
│
│ (Tailwind đọc lúc build)
▼
┌─────────────────────────────────────────────┐
│ 1) Phát biến vào :root │
│ :root { │
│ --color-brand: #6366f1; │
│ --radius-card: 12px; │
│ } │
├─────────────────────────────────────────────┤
│ 2) Sinh utility, mỗi cái trỏ về biến đó │
│ .bg-brand { background-color: │
│ var(--color-brand); } │
│ .text-brand { color: var(--color-brand);}│
│ .rounded-card { border-radius: │
│ var(--radius-card); } │
└─────────────────────────────────────────────┘
Hai hệ quả quan trọng từ sơ đồ này:
- Utility không “nhúng cứng” giá trị — nó nhúng
var(--color-brand). Nên nếu bạn đổi--color-brandở một scope con (vd.darkhay một section), mọibg-brandtrong scope đó tự đổi mà không cần class mới. Đây là nền tảng của theming. - Biến nằm ở
:root→ mở DevTools, chọn<html>, tab Computed/Styles bạn sẽ thấy toàn bộ token. Nếu một utility “không ăn màu”, kiểm tra xem biến tương ứng có tồn tại không — nếu không, token bị đặt sai namespace hoặc sai chỗ.
Mẹo debug: mở DevTools → inspect element dùng
bg-brand→ xem rule.bg-brand. Nếu nó làbackground-color: var(--color-brand)nhưng màu không hiện, nghĩa là--color-brandchưa được định nghĩa (token sai tên). Nếu không thấy rule.bg-brandnào, nghĩa là Tailwind chưa sinh class — token chưa nằm trong@themehoặc bạn quên@import "tailwindcss".
5. Mở rộng vs thay thế mặc định
Mặc định, @theme thêm vào (extend) bộ token có sẵn của Tailwind. --color-brand của bạn nhập hội cùng --color-slate-500, --color-red-500… vẫn còn nguyên. Đây là hành vi bạn muốn 90% thời gian.
@theme {
/* brand của bạn SỐNG CHUNG với toàn bộ palette mặc định */
--color-brand: #6366f1;
}
/* → vẫn dùng được bg-slate-100, text-red-600, … lẫn bg-brand */
Khi nào cần xóa sạch một namespace rồi xây lại từ đầu? Khi bạn muốn một design system khép kín, không cho lập trình viên “lỡ tay” dùng 22 bảng màu mặc định và đi chệch hệ thống. Reset bằng cú pháp namespace-*: initial:
@theme {
/* Xóa TOÀN BỘ màu mặc định, chỉ giữ cái bạn định nghĩa */
--color-*: initial;
--color-bg: #0a0a0a;
--color-fg: #e5e5e5;
--color-muted: #a1a1aa;
--color-brand: #c8ff00;
}
Sau khối này, bg-slate-100 không còn tồn tại — chỉ bg-bg, bg-fg, bg-muted, bg-brand hợp lệ. Bảng cú pháp reset cho từng tình huống:
| Bạn muốn | Viết | Kết quả |
|---|---|---|
| Thêm token, giữ mặc định | --color-brand: #6366f1; | brand + toàn bộ palette gốc |
| Xóa sạch 1 namespace rồi xây mới | --color-*: initial; rồi khai lại | chỉ còn token bạn định nghĩa |
| Xóa đúng 1 token mặc định | --color-lime-*: initial; | mọi bg-lime-… biến mất, các màu khác còn |
| Xóa mọi thứ mọi namespace | --*: initial; | bắt đầu từ con số 0 tuyệt đối (hiếm khi nên) |
Cẩn thận với
--*: initial. Nó xóa cả--spacing-*,--text-*,--breakpoint-*… nghĩa làp-4,text-sm,md:đều biến mất. Chỉ dùng khi bạn thực sự muốn dựng một hệ thống hoàn toàn riêng và sẵn sàng khai lại mọi thang.
6. Tham chiếu token bằng var()
Vì token là biến CSS thật ở :root, bạn tham chiếu chúng ở bất kỳ đâu CSS chấp nhận var() — không chỉ trong utility:
/* CSS thủ công vẫn ăn theo cùng nguồn sự thật */
.custom-gradient {
background: linear-gradient(
to right,
var(--color-brand),
var(--color-brand-foreground)
);
border-radius: var(--radius-card);
}
Đọc được cả từ JavaScript lúc runtime — hữu ích khi vẽ canvas, chart, hay truyền màu cho thư viện JS:
const brand = getComputedStyle(document.documentElement)
.getPropertyValue('--color-brand')
.trim(); // "#6366f1"
Token cũng tham chiếu lẫn nhau và tính toán bằng calc() — cách dựng thang phái sinh từ một giá trị gốc:
@theme {
--spacing-base: 0.25rem;
--radius-card: 12px;
--radius-card-lg: calc(var(--radius-card) * 1.5); /* 18px, luôn = 1.5× card */
}
Chốt: utility là lối tắt tiện cho 95% trường hợp;
var(--token)là cửa hậu cho 5% còn lại (CSS thủ công, JS, phép tính). Cả hai rút từ cùng một token → không bao giờ lệch nhau.
7. @theme inline — khi token tham chiếu biến khác
Mặc định, giá trị bạn viết trong @theme được Tailwind “đóng băng” vào utility lúc build. Điều này gây vấn đề khi token của bạn trỏ tới một biến khác sẽ đổi lúc runtime — ví dụ token màu trỏ tới biến do shadcn/ui hay một theme provider đặt:
@theme {
/* VẤN ĐỀ: Tailwind có thể "chụp" giá trị --brand-raw tại thời điểm build,
utility sẽ không phản ứng khi --brand-raw đổi runtime */
--color-brand: var(--brand-raw);
}
Dùng @theme inline để Tailwind nội tuyến (inline) var() vào utility thay vì giải giá trị lúc build — nhờ đó utility luôn phản ứng theo biến gốc lúc runtime:
:root {
--brand-raw: #6366f1;
}
@theme inline {
/* bg-brand sẽ là: background-color: var(--brand-raw) ← live, đổi runtime được */
--color-brand: var(--brand-raw);
}
Bảng phân biệt nhanh:
@theme (thường) | @theme inline | |
|---|---|---|
Token trỏ tới giá trị tĩnh (#6366f1) | ✅ Dùng cái này | không cần |
Token trỏ tới var(--khác) đổi runtime | utility có thể không “live” | ✅ Dùng cái này |
shadcn/ui mapping (--color-background: var(--background)) | — | ✅ Đây là lý do shadcn dùng @theme inline |
Đây chính là mẫu shadcn/ui dùng để nối hệ token ngữ nghĩa của nó vào Tailwind — bạn sẽ gặp lại nguyên si ở Phần 10.
8. Dark mode kiểu v4
Tailwind có sẵn variant dark:. Mặc định nó bám theo cài đặt OS qua prefers-color-scheme:
<div class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">…</div>
Cơ chế: dark:bg-slate-900 sinh ra CSS gói trong media query.
@media (prefers-color-scheme: dark) {
.dark\:bg-slate-900 { background-color: #0f172a; }
}
Vấn đề: cách này chỉ theo OS, người dùng không bật/tắt bằng tay được. App thật thường muốn một nút toggle. Giải pháp: đổi định nghĩa của variant dark để nó bám vào class .dark trên cha thay vì media query, bằng @custom-variant:
/* src/index.css */
@import "tailwindcss";
/* dark: phản ứng theo class .dark thay vì OS */
@custom-variant dark (&:where(.dark, .dark *));
Cú pháp &:where(.dark, .dark *) nghĩa là “áp dụng khi chính phần tử hoặc tổ tiên của nó có class .dark”, và :where() giữ specificity bằng 0 để không gây chiến tranh override. Sau dòng này, dark: sinh ra CSS kiểu:
.dark .dark\:bg-slate-900 { background-color: #0f172a; }
Rồi bạn bật/tắt class .dark trên <html> từ JS và lưu lựa chọn:
// Toggle + persist
const root = document.documentElement;
function applyTheme(theme: 'light' | 'dark') {
root.classList.toggle('dark', theme === 'dark');
localStorage.theme = theme;
}
function toggleTheme() {
applyTheme(root.classList.contains('dark') ? 'light' : 'dark');
}
Chống FOUC (nháy sai theme khi tải trang). Nếu bạn đợi React mount rồi mới set class, người dùng thấy một “chớp” theme sáng trước khi chuyển tối. Khắc phục bằng một đoạn script chạy đồng bộ trước khi paint, đặt trong <head>:
<!-- index.html, đặt trong <head> TRƯỚC mọi CSS/JS khác -->
<script>
// Chạy trước khi trình duyệt vẽ → không có nháy
const saved = localStorage.theme;
const prefersDark = matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark');
}
</script>
Chiến lược kết hợp (tốt nhất cho sản phẩm): mặc định theo OS cho tới khi người dùng chọn tay, rồi tôn trọng lựa chọn của họ. Logic gói gọn trong đúng if ở trên — saved thắng OS khi tồn tại; chưa chọn thì rơi về prefers-color-scheme.
| Chiến lược | Cơ chế | Khi nào dùng |
|---|---|---|
| Theo OS | prefers-color-scheme (mặc định của dark:) | Trang tĩnh, blog, không cần toggle |
| Toggle bằng class | @custom-variant dark (&:where(.dark, .dark *)) + JS | App có nút bật/tắt theme |
| Kết hợp | OS làm mặc định, localStorage override khi user chọn | Sản phẩm thật — trải nghiệm tốt nhất |
9. Token ngữ nghĩa — mẫu mở rộng tốt nhất
Cách dark mode kém scale: rải dark: lên từng phần tử (bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 — nhân đôi mọi class). Cách scale tốt: định nghĩa token ngữ nghĩa (đặt tên theo vai trò, không theo màu), rồi định nghĩa lại giá trị của chúng dưới .dark. Component chỉ tham chiếu vai trò, không bao giờ biết theme nào đang bật:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* tên theo VAI TRÒ, không theo màu */
--color-bg: #ffffff;
--color-fg: #0f172a;
--color-card: #f8fafc;
--color-border: #e2e8f0;
--color-primary: #6366f1;
}
/* chỉ ĐỔI GIÁ TRỊ token dưới .dark — utility giữ nguyên */
.dark {
--color-bg: #0b1120;
--color-fg: #e2e8f0;
--color-card: #111827;
--color-border: #1f2937;
--color-primary: #818cf8;
}
<!-- một bộ class duy nhất, đúng ở CẢ hai theme — không cần dark: -->
<div class="bg-card text-fg border border-border rounded-xl p-6">
<h3 class="text-fg font-semibold">Thẻ tự đổi theme</h3>
<p class="text-fg/70 mt-2 text-sm">Không một class dark: nào.</p>
<button class="bg-primary mt-4 rounded-md px-4 py-2 text-white">Hành động</button>
</div>
So sánh hai cách trên cùng một card:
| Tiêu chí | Rải dark: mỗi phần tử | Token ngữ nghĩa override ở .dark |
|---|---|---|
| Số class mỗi phần tử | gấp đôi (sáng + tối) | một bộ |
| Thêm theme thứ ba | sửa mọi file | thêm một khối .theme-x { … } |
| Đổi sắc tối | sửa khắp nơi | sửa một chỗ trong .dark |
| Rủi ro quên một phần tử | cao | gần như không |
Mẫu token ngữ nghĩa này chính xác là thứ shadcn/ui dùng (
--background,--foreground,--primary,--muted…). Nắm vững ở đây, Phần 10 chỉ là “à, ra vậy”.
10. Giá trị tùy ý — cửa thoát hiểm
Khi một giá trị lẻ không có trong thang/token, dùng ngoặc vuông — nhưng coi đó là mùi code, không phải mặc định:
<div class="top-[117px] bg-[#1da1f2] w-[42ch]">…</div>
Bạn cũng tham chiếu thẳng một biến CSS bằng cú pháp (--var):
<!-- dùng biến runtime không khai trong @theme -->
<div class="bg-(--brand-raw) text-(length:--my-size)">…</div>
Quy tắc thực dụng: dùng arbitrary value một lần thì chấp nhận được; thấy mình lặp lại cùng một giá trị hai lần trở lên → nâng nó thành token trong @theme. Đó là ranh giới giữa một codebase trôi dạt và một codebase giữ được tính hệ thống.
11. @theme thay tailwind.config.js như thế nào
Nếu bạn đến từ v3, đây là bảng “dịch” trực tiếp từ config JS sang @theme CSS:
v3 — tailwind.config.js | v4 — @theme trong CSS |
|---|---|
theme.extend.colors.brand = '#6366f1' | --color-brand: #6366f1; |
theme.extend.borderRadius.card = '12px' | --radius-card: 12px; |
theme.extend.fontFamily.display = [...] | --font-display: "Cal Sans", sans-serif; |
theme.extend.spacing.gutter = '1.25rem' | --spacing-gutter: 1.25rem; |
theme.extend.screens.tablet = '960px' | --breakpoint-tablet: 960px; |
theme.colors = { … } (thay sạch) | --color-*: initial; rồi khai lại |
darkMode: 'class' | @custom-variant dark (&:where(.dark, .dark *)); |
theme.extend.boxShadow.card = '…' | --shadow-card: …; |
Khác biệt cốt lõi không chỉ là cú pháp:
- Token thành biến CSS runtime — điều
tailwind.config.jskhông làm. Config JS chỉ tồn tại lúc build; token v4 sống cả lúc chạy, đọc được từ JS/DevTools, override được theo scope. - Một nguồn, một file — màu, spacing, breakpoint nằm cùng chỗ với CSS, không nhảy sang file JS riêng.
Vẫn cần file JS chứ? Hiếm. v4 cho phép
@config "./tailwind.config.js"để dùng lại config cũ khi nâng cấp dần, nhưng đích đến là chuyển hết vào@theme. Plugin phức tạp (vd thêm utility tùy biến) vẫn có thể cần JS, nhưng phần token nên ở@theme.
12. Recipe thực tế
12.1 — Bộ màu brand đầy đủ (50 → 950)
Đừng chỉ khai một --color-brand. Khai cả thang sắc độ để có bg-brand-50, text-brand-700… như màu mặc định:
@theme {
--color-brand-50: #eef2ff;
--color-brand-100: #e0e7ff;
--color-brand-200: #c7d2fe;
--color-brand-300: #a5b4fc;
--color-brand-400: #818cf8;
--color-brand-500: #6366f1; /* sắc "chuẩn" */
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
--color-brand-800: #3730a3;
--color-brand-900: #312e81;
--color-brand-950: #1e1b4b;
}
<span class="bg-brand-50 text-brand-700 ring-brand-200 rounded-full px-3 py-1 text-sm ring-1">
Pro
</span>
<button class="bg-brand-600 hover:bg-brand-700 px-4 py-2 text-white">Mua</button>
12.2 — Chuyển nhiều theme (hơn cả light/dark)
Token ngữ nghĩa cho phép bao nhiêu theme cũng được — mỗi theme là một khối ghi đè biến, chọn bằng data-theme trên <html>:
@theme {
--color-bg: #ffffff;
--color-fg: #0f172a;
--color-primary: #6366f1;
}
[data-theme="dark"] {
--color-bg: #0b1120;
--color-fg: #e2e8f0;
--color-primary: #818cf8;
}
[data-theme="solarized"] {
--color-bg: #fdf6e3;
--color-fg: #073642;
--color-primary: #b58900;
}
function ThemeSwitcher() {
const setTheme = (t: string) => {
document.documentElement.dataset.theme = t;
localStorage.theme = t;
};
return (
<select
defaultValue={localStorage.theme ?? 'light'}
onChange={(e) => setTheme(e.target.value)}
className="bg-bg text-fg border-primary rounded-md border px-3 py-1.5"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="solarized">Solarized</option>
</select>
);
}
Mọi component dùng bg-bg, text-fg, bg-primary tự đúng ở cả ba theme — không sửa một dòng markup nào.
12.3 — Tách primitive và semantic token
Một pattern bền hơn cho sản phẩm là giữ palette gốc và token vai trò tách nhau. Palette có thể đầy đủ brand-50..950; component không dùng trực tiếp brand-600, mà dùng primary, primary-foreground, muted, border.
@theme {
/* Primitive palette */
--color-brand-50: #eef2ff;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
/* Semantic roles */
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-primary: var(--color-brand-600);
--color-primary-foreground: #ffffff;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-border: #e2e8f0;
}
.dark {
--color-background: #020617;
--color-foreground: #e2e8f0;
--color-primary: var(--color-brand-500);
--color-muted: #0f172a;
--color-muted-foreground: #94a3b8;
--color-border: #1e293b;
}
<section class="bg-background text-foreground">
<button class="rounded-md bg-primary px-4 py-2 text-primary-foreground">
Continue
</button>
<p class="text-muted-foreground">Nội dung phụ dùng semantic token.</p>
</section>
Lợi ích:
- Đổi brand chỉ cần đổi primitive hoặc mapping
--color-primary. - Component không biết màu thật là gì, chỉ biết vai trò UI.
- Dark mode chỉ override semantic token cần đổi; markup giữ nguyên.
Lưu ý v4: nếu semantic token trỏ tới token/biến khác và bạn cần đảm bảo nó live theo runtime variable, dùng
@theme inlinenhư mục 7. Với giá trị tĩnh hoặc token cùng file không đổi runtime,@themethường là đủ.
12.4 — Quy tắc đặt tên token
Tên token tốt giúp dev không phải đoán. Một vài quy tắc thực dụng:
| Nên | Tránh | Vì sao |
|---|---|---|
--color-primary | --color-blue-button | Vai trò bền hơn màu hiện tại |
--color-muted-foreground | --color-gray-text | Màu xám có thể đổi theo theme |
--radius-card | --radius-12px | Component cần vai trò, không cần biết con số |
--spacing-page-x | --spacing-big | ”big” mơ hồ; page-x nói rõ mục đích |
--shadow-popover | --shadow-cool | Shadow nên gắn với elevation/role |
Một rule đơn giản: nếu tên token chứa màu cụ thể (blue, red, gray) nhưng đang được dùng như vai trò UI, hãy đổi sang tên semantic. Primitive token được phép chứa màu; semantic token nên chứa ý nghĩa.
13. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Khai token nhưng bg-… không tồn tại | Prefix sai namespace (vd dùng --brand-color thay vì --color-brand) | Đặt prefix khớp namespace: màu phải bắt đầu --color-, radius --radius-… (mục 3) |
| Token có nhưng utility không sinh | Khai biến trong :root thay vì @theme | Token phải nằm trong @theme mới sinh utility; :root chỉ tạo biến, không tạo class |
| Đổi token runtime nhưng utility “trơ” | Token trỏ tới var(--khác) mà dùng @theme thường | Dùng @theme inline để nội tuyến var() (mục 7) |
dark: không phản ứng khi bấm toggle | Chưa override variant; nó vẫn theo OS | Thêm @custom-variant dark (&:where(.dark, .dark *)) và bật class .dark (mục 8) |
| Nháy theme sáng lúc tải trang (FOUC) | Set class .dark sau khi React mount | Đặt script đồng bộ set class trong <head> trước paint (mục 8) |
Reset --*: initial xong p-4, text-sm biến mất | --* xóa mọi namespace kể cả spacing/text | Chỉ reset namespace cụ thể (--color-*: initial), tránh --* trừ khi cố ý |
Override token trong .dark không ăn | .dark đặt trên @theme nên bị :root (specificity bằng) ghi đè theo thứ tự | Đặt khối .dark { … } sau @theme, hoặc tăng cụ thể bằng html.dark |
Hai gotcha hay gặp nhất: (1) sai namespace prefix → utility không tồn tại, và (2) quên
@custom-variant→ toggle dark mode không chạy. Nhớ hai cái này là tránh được phần lớn rắc rối theming.
14. Bài tập
1. Định nghĩa token --color-brand và --radius-card là 16px, rồi dựng nút dùng bg-brand và rounded-card.
Lời giải
@theme {
--color-brand: #14b8a6;
--radius-card: 16px;
}<button class="bg-brand rounded-card px-4 py-2 text-white">Go</button>2. Thiết lập dark mode bật tay (theo class) và làm một card đọc được ở cả hai theme bằng token ngữ nghĩa bg-bg/text-fg.
Lời giải
@custom-variant dark (&:where(.dark, .dark *));
@theme { --color-bg: #fff; --color-fg: #0f172a; }
.dark { --color-bg: #0b1120; --color-fg: #e2e8f0; }<div class="bg-bg text-fg p-6 rounded-card">Readable in both themes</div>3. Bạn thấy mình viết mt-[18px] ở ba file. Sửa thế nào?
Lời giải
Nâng thành token spacing: --spacing-section: 18px; trong @theme, rồi dùng mt-section (cũng được p-section, gap-section… nhờ namespace --spacing-*).
4. Bạn khai --brand-primary: #6366f1; trong @theme nhưng bg-brand-primary báo không tồn tại. Sai ở đâu, sửa thế nào?
Lời giải
Prefix sai namespace. Utility màu chỉ sinh từ namespace --color-*. Đổi tên thành --color-brand-primary: #6366f1; → khi đó bg-brand-primary, text-brand-primary mới tồn tại.
5. Một designer đưa palette riêng và yêu cầu cấm dùng màu mặc định của Tailwind. Viết @theme để chỉ còn bg-bg, bg-fg, bg-accent hợp lệ, mọi bg-slate-… biến mất.
Lời giải
@theme {
--color-*: initial; /* xóa sạch palette mặc định */
--color-bg: #0a0a0a;
--color-fg: #e5e5e5;
--color-accent: #c8ff00;
}Sau khối này bg-slate-100 không còn tồn tại — chỉ ba màu bạn khai là hợp lệ.
6. Token --color-brand: var(--brand-raw) của bạn không đổi khi JS cập nhật --brand-raw lúc runtime. Vì sao, và sửa một từ khóa nào?
Lời giải
@theme thường giải var() lúc build nên utility “đóng băng” giá trị. Đổi sang @theme inline { --color-brand: var(--brand-raw); } để Tailwind nội tuyến var() vào utility → nó phản ứng theo --brand-raw lúc runtime (mục 7).
Nâng cao: trong token studio, chọn bộ màu brand + radius + cỡ chữ bạn thích, rồi viết đúng khối @theme tái tạo nó — kèm một khối .dark override token ngữ nghĩa.
Điểm chính
- Cấu hình v4 là CSS-first:
@themechứa design token, thay chotailwind.config.js. - Mỗi token làm hai việc: phát một biến CSS vào
:rootvà sinh nhóm utility tương ứng — theo quy ước namespace (--color-*→bg/text/border/ring…,--spacing-*→p/m/w/h/gap…,--radius-*→rounded-*,--text-*→text-*,--breakpoint-*→ variant responsive…). - Utility không nhúng cứng giá trị mà nhúng
var(--token)→ đổi biến ở một scope (vd.dark) là mọi utility trong scope đổi theo. Đây là nền tảng của theming. @thememặc định mở rộng mặc định;namespace-*: initialđể thay thế;@theme inlinecho token trỏ tới biến runtime.- Dark mode: mặc định theo OS (
prefers-color-scheme); muốn toggle tay thì@custom-variant dark (&:where(.dark, .dark *))+ bật class.dark+ script chống FOUC trong<head>. - Token ngữ nghĩa (đặt tên theo vai trò, override giá trị ở
.dark/[data-theme]) là mẫu scale tốt nhất — và chính là cách shadcn/ui theme component (Phần 10).
Tiếp theo
Phần 4 — Variant, trạng thái & kết hợp: hover:, focus:, disabled:, mẫu mạnh mẽ group-* và peer-*, style theo data-[state=open]: (chìa khóa cho Radix/shadcn), và cách variant chồng lên nhau.