Tailwind, Radix & shadcn/ui · Part 10 — Theming & Customizing shadcn
How the CSS-variable theme works, building light/dark, generating a brand theme, customizing a copied component, and the registry system for sharing your own. With a live theme generator.
Component shadcn đẹp sẵn, nhưng cốt lõi của việc sở hữu code là làm chúng trông giống của bạn, không phải “lại một app shadcn mặc định nữa”. Tin tốt: theme của shadcn không phải một hệ thống bí ẩn — nó chính là mẫu token ngữ nghĩa từ Phần 3, áp dụng có kỷ luật. Khi bạn thật sự hiểu lớp biến CSS này, việc re-skin toàn bộ app chỉ còn là sửa một nhúm biến — và mọi Button, Card, Dialog, focus ring đi theo miễn phí.
Bài này mổ xẻ từng tầng: bộ token đầy đủ và vai trò của từng cái, cách :root/.dark định nghĩa chúng, vì sao shadcn v4 chọn màu OKLCH, cách @theme inline nối token vào utility Tailwind, cách sửa mã nguồn component để thêm variant của riêng bạn, khác biệt new-york vs default, nối dây dark mode chuẩn, và dựng một brand theme từ đầu.
Theme governance: đổi ít, lan rộng
Một theme tốt không phải là theme có nhiều màu. Một theme tốt là theme có ít quyết định trung tâm nhưng đủ diễn đạt mọi UI.
| Quyết định | Nên nằm ở đâu | Ví dụ |
|---|---|---|
| Màu nền/chữ/primary/destructive | token shadcn trong :root/.dark | --primary, --background, --destructive |
| Màu riêng cho chart/sidebar | token domain đã có sẵn | --chart-1, --sidebar-accent |
| Variant component mới | cva của component đã copy | buttonVariants.variant.success |
| Ngoại lệ một màn | className tại nơi dùng | className="lg:max-w-3xl" |
| Giá trị lặp lại nhiều nơi | token mới hoặc variant mới | --success, variant="success" |
Nếu thấy mình viết cùng một màu arbitrary như bg-[#16a34a] ở nhiều component, đó là tín hiệu nâng nó thành token hoặc variant. Nếu thấy mình thêm token cho một giá trị dùng đúng một lần, đó là hệ thống đang phình. Giữ theme gọn là một kỹ năng thiết kế, không chỉ kỹ năng CSS.
1. Theme shadcn chỉ là ba tầng biến CSS
Khi bạn chạy npx shadcn@latest init, nó ghi một khối token ngữ nghĩa vào stylesheet gốc (app/globals.css hoặc src/index.css). Mọi component shadcn chỉ tham chiếu các tên ngữ nghĩa này — không bao giờ màu thô như bg-zinc-900. Đó là lý do một thay đổi nhỏ lan ra toàn hệ thống.
Hãy hình dung kiến trúc thành ba tầng tách bạch, mỗi tầng có một việc:
TẦNG 1 — Giá trị thật (primitive)
:root { --primary: oklch(0.205 0 0); … } ← màu/khoảng cách cụ thể
.dark { --primary: oklch(0.922 0 0); … } ← cùng tên, giá trị khác
│
│ @theme inline nối tên ngữ nghĩa → token Tailwind
▼
TẦNG 2 — Cầu nối (Phần 3)
@theme inline { --color-primary: var(--primary); … }
│
│ Tailwind sinh utility từ token
▼
TẦNG 3 — Utility bạn gõ trong markup
bg-primary text-primary-foreground border-input ring-ring
│
▼
<Button> đọc các utility này → tự đổi theo theme
Điểm cốt lõi: component ở tầng 3 không biết màu thật là gì. Bạn đổi tầng 1, mọi thứ phía trên cập nhật. Đây chính là cơ chế @theme inline đã học ở Phần 3 — và shadcn là ứng dụng “thực chiến” lớn nhất của nó.
Vì sao tách tầng? Nếu component viết thẳng
bg-zinc-900, bạn phải sửa hàng trăm chỗ để đổi brand, và dark mode là ác mộng. Với token ngữ nghĩa, “primary” là một khái niệm — màu thật chỉ là chi tiết cài đặt, đổi ở một nơi.
2. Bộ token ngữ nghĩa đầy đủ — vai trò từng cái
Đây là bảng tra toàn bộ token shadcn v4 ghi ra, kèm vai trò. Thuộc bảng này là thuộc 80% việc theming — vì mọi component chỉ ghép các tên dưới đây.
| Token | Utility tương ứng | Vai trò |
|---|---|---|
--background | bg-background | Nền trang gốc (body) |
--foreground | text-foreground | Chữ mặc định trên nền gốc |
--card | bg-card | Nền của bề mặt nổi (Card, panel) |
--card-foreground | text-card-foreground | Chữ trên card |
--popover | bg-popover | Nền lớp nổi tạm (Dropdown, Tooltip, Popover) |
--popover-foreground | text-popover-foreground | Chữ trong popover |
--primary | bg-primary | Màu hành động chính (nút Submit, link nhấn) |
--primary-foreground | text-primary-foreground | Chữ trên nền primary |
--secondary | bg-secondary | Hành động phụ, ít nổi hơn |
--secondary-foreground | text-secondary-foreground | Chữ trên nền secondary |
--muted | bg-muted | Nền trầm cho vùng phụ (skeleton, hàng disabled) |
--muted-foreground | text-muted-foreground | Chữ phụ, caption, placeholder |
--accent | bg-accent | Nền nhấn nhẹ khi hover item (menu, list) |
--accent-foreground | text-accent-foreground | Chữ trên nền accent |
--destructive | bg-destructive | Hành động nguy hiểm (xóa), lỗi |
--destructive-foreground* | text-destructive-foreground | Chữ trên nền destructive (xem ghi chú v4) |
--border | border-border | Màu viền mặc định mọi nơi |
--input | border-input | Viền của ô input/select/textarea |
--ring | ring-ring | Vòng focus (focus-visible:ring-ring) |
--radius | (suy ra rounded-*) | Bán kính bo góc gốc — xem mục 7 |
--chart-1…--chart-5 | fill-chart-1… | 5 màu cho biểu đồ (Recharts) |
--sidebar | bg-sidebar | Nền riêng cho khối Sidebar |
--sidebar-foreground | text-sidebar-foreground | Chữ trong sidebar |
--sidebar-primary | bg-sidebar-primary | Item active trong sidebar |
--sidebar-primary-foreground | — | Chữ trên item active |
--sidebar-accent | bg-sidebar-accent | Hover item sidebar |
--sidebar-accent-foreground | — | Chữ khi hover |
--sidebar-border | border-sidebar-border | Viền trong sidebar |
--sidebar-ring | ring-sidebar-ring | Focus ring trong sidebar |
* Ở v4 default, một số nút destructive dùng thẳng
text-whitethay vì--destructive-foregroundđể tương phản chắc chắn trên nền đỏ. Bạn vẫn có thể tự thêm token--destructive-foregroundnếu muốn theme nó.
Nhìn bảng sẽ thấy một luật đặt tên lặp đi lặp lại: gần như mọi token nền X đều có cặp X-foreground. Đó là hợp đồng tương phản — ta mổ kỹ ở mục 6.
3. :root và .dark — cùng tên, hai bộ giá trị
Cơ chế dark mode của shadcn đơn giản đến bất ngờ: cùng một danh sách token, định nghĩa hai lần. :root cho sáng, .dark cho tối. Vì component chỉ đọc tên token, việc thêm class .dark lên <html> đổi toàn bộ bảng màu mà không component nào phải biết.
Đây là khối shadcn v4 (default style) ghi ra — rút gọn nhưng đủ thấy cấu trúc:
/* src/index.css — sau @import "tailwindcss"; */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0); /* trắng tinh */
--foreground: oklch(0.145 0 0); /* gần đen */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); /* đen-than làm màu chính */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); /* xám trung */
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); /* đỏ */
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
/* …chart-3..5, sidebar-*… */
}
.dark {
--background: oklch(0.145 0 0); /* nền tối */
--foreground: oklch(0.985 0 0); /* chữ sáng */
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.269 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); /* primary đảo sáng ở dark */
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); /* viền = trắng 10% alpha */
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
/* …mọi token còn lại định nghĩa lại cho tối… */
}
Hai chi tiết đáng chú ý:
- Ở
.dark,--primaryđảo sáng (0.205→0.922). Vì nền tối, màu chính phải sáng lên để nổi. Token ngữ nghĩa cho phép “primary” mang ý nghĩa khác nhau theo ngữ cảnh. --borderở dark dùngoklch(1 0 0 / 10%)— trắng với alpha 10%. Viền mờ trên nền tối trông tự nhiên hơn một màu xám đặc.
Quy tắc vàng: mỗi khi thêm một token mới (ví dụ
--success), bạn phải định nghĩa nó ở cả:rootvà.dark. Quên một bên → component vỡ màu ở một theme. Đây là gotcha số 1 khi mở rộng theme.
4. Vì sao là OKLCH (không phải hex/HSL)
shadcn v4 chuyển sang định dạng màu OKLCH. Cú pháp là oklch(L C H) hoặc oklch(L C H / alpha):
| Thành phần | Ý nghĩa | Khoảng giá trị |
|---|---|---|
L — Lightness | Độ sáng cảm nhận | 0 (đen) → 1 (trắng) |
C — Chroma | Độ rực/bão hòa | 0 (xám) → ~0.4 (rực nhất) |
H — Hue | Sắc (góc bánh xe màu) | 0–360 (độ) |
Ví dụ: oklch(0.62 0.19 260) = sáng 62%, khá rực, hue 260° (xanh tím). oklch(0.97 0 0) = sáng 97%, chroma 0 → xám trung tính (hue không quan trọng khi C=0).
Vì sao bỏ hex/HSL? Ba lý do thực dụng:
- Đồng đều theo cảm nhận. Trong HSL, đổi cùng một lượng lightness ở hue vàng và hue xanh dương cho ra độ sáng thấy khác hẳn. OKLCH thiết kế để
Lkhớp với cảm nhận mắt người — nên một thangLđều tay cho ra một thang màu thấy cũng đều. - Tạo dải màu cực dễ. Muốn một thang brand sáng→tối? Giữ
CvàH, chỉ kéoL. Trong hex bạn phải dò từng giá trị. - Gam màu rộng (P3). OKLCH biểu diễn được màu rực ngoài sRGB mà màn hình hiện đại hiển thị được — hex không với tới.
/* Một dải primary nhất quán: chỉ đổi L, giữ nguyên C & H */
--primary-100: oklch(0.95 0.03 260);
--primary-500: oklch(0.62 0.19 260); /* màu chuẩn */
--primary-900: oklch(0.30 0.12 260);
Cơ chế dưới mui xe: trình duyệt hiện đại (2023+) hiểu
oklch()native. Nếu cần hỗ trợ trình duyệt rất cũ, công cụ build có thể fallback, nhưng với đối tượng app shadcn thường nhắm thì OKLCH chạy thẳng.
5. @theme inline — nối token vào utility Tailwind
Tới đây mới có bg-primary. Bản thân khối :root chỉ định nghĩa biến CSS — Tailwind chưa biết sinh utility bg-primary. Cầu nối là @theme inline, đúng mẫu đã học ở Phần 3:
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* …một dòng cho mỗi token ngữ nghĩa… */
}
Vì sao inline chứ không phải @theme thường? Đây chính là bài học cốt lõi Phần 3: token shadcn trỏ tới một biến khác (var(--primary)) mà giá trị sẽ đổi lúc runtime khi class .dark bật lên. @theme inline bảo Tailwind nội tuyến var() vào utility:
/* @theme inline sinh ra: */
.bg-primary { background-color: var(--primary); } /* ✅ live — đổi theo .dark */
/* @theme thường (sai cho ca này) sẽ "đóng băng": */
.bg-primary { background-color: oklch(0.205 0 0); } /* ❌ kẹt màu sáng, dark mode chết */
Chốt:
:root/.dark= giá trị;@theme inline= cầu nối tên → utility. Thiếu cầu nối, class không tồn tại; dùng@themethường thay vìinline, dark mode chết cứng.
6. Hợp đồng * ↔ *-foreground
Luật đặt tên ở mục 2 không phải ngẫu nhiên — nó là một hợp đồng tương phản. Mỗi token nền --X đi kèm --X-foreground được chỉnh sẵn để đọc rõ trên nền đó, ở cả hai theme. Nên bạn luôn viết theo cặp:
<div class="bg-primary text-primary-foreground">luôn đọc được</div>
<div class="bg-muted text-muted-foreground">vùng phụ, vẫn rõ chữ</div>
<div class="bg-card text-card-foreground">bề mặt nổi</div>
<div class="bg-accent text-accent-foreground">item hover</div>
Vì sao điều này “tự đúng” qua các theme? Ở :root, --primary tối + --primary-foreground sáng → tương phản tốt. Ở .dark, cả hai đảo lại → vẫn tương phản tốt. Bạn không phải nghĩ về dark mode ở tầng component — hợp đồng lo việc đó.
| Cặp đúng (✅) | Cặp sai (⚠️) | Vì sao |
|---|---|---|
bg-primary text-primary-foreground | bg-primary text-white | text-white có thể chìm khi primary sáng (dark mode) |
bg-muted text-muted-foreground | bg-muted text-foreground | text-foreground quá đậm, mất sắc thái “phụ” |
bg-card text-card-foreground | bg-card text-black | Đen cứng vỡ ở dark mode |
Quy tắc thực chiến: thấy
bg-*mà không cótext-*-foregroundđi kèm → khựng lại. 90% là bug tương phản chực chờ ở theme còn lại.
Chọn màu/radius và xem mọi component re-skin trực tiếp, kèm CSS chính xác để paste — chuyển light/dark trong demo để thấy hợp đồng *-foreground tự giữ tương phản:
7. Token --radius — một số định hình mọi góc
--radius đặc biệt: thay vì gắn một utility màu, nó là hạt giống mà cả thang rounded-* suy ra. Nhìn lại @theme inline ở mục 5:
--radius-sm: calc(var(--radius) - 4px); /* nhỏ hơn gốc 4px */
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); /* = gốc */
--radius-xl: calc(var(--radius) + 4px); /* lớn hơn gốc 4px */
Nên đổi một dòng --radius: 0.625rem thành --radius: 0 cho UI vuông góc sắc cạnh, hay --radius: 1rem cho phong cách bo tròn mềm mại — toàn bộ Button, Card, Input, Dialog đổi đồng loạt, giữ đúng tỷ lệ tương đối giữa các cỡ.
:root { --radius: 0rem; } /* phong cách "sharp" — mọi góc vuông */
:root { --radius: 1.25rem; } /* phong cách "soft" — bo tròn mạnh */
Vì các cỡ dùng
calc(... - 4px), đặt--radiusquá nhỏ có thể cho ra số âm ởradius-sm. Trình duyệt kẹp về 0, nên không vỡ — nhưng đừng ngạc nhiên khirounded-smvàrounded-mdtrông giống nhau ở radius thấp.
8. Style “new-york” vs “default”
Lúc init, CLI hỏi chọn style. Đây không phải theme màu — mà là bộ mặc định khác nhau cho mã nguồn component sinh ra:
default | new-york | |
|---|---|---|
| Cảm giác chung | Mềm, nhiều khoảng thở | Gọn, đặc, sắc nét hơn |
| Bóng (shadow) | Rõ hơn (shadow-sm→md) | Tinh giản, ít bóng |
| Cỡ & spacing | Rộng rãi hơn | Chặt hơn (nút thấp hơn chút) |
| Icon mặc định | lucide-react | lucide-react (đậm/nét khác) |
| Khuyến nghị hiện tại | — | shadcn đặt new-york làm mặc định mới |
Điểm mấu chốt: style chỉ ảnh hưởng lúc sinh code. Sau khi component nằm trong components/ui/, nó là của bạn — đổi style trong components.json về sau không sửa lại file đã có. Muốn đổi cảm giác toàn cục, bạn sửa token + class trực tiếp, không “đổi style” được nữa.
// components.json — chọn lúc init, không hồi tố
{
"style": "new-york",
"tailwind": { "css": "src/index.css", "baseColor": "neutral" },
"aliases": { "components": "@/components", "ui": "@/components/ui" }
}
9. Nối dây dark mode đúng chuẩn
Khối .dark đã sẵn; việc còn lại là bật/tắt class .dark trên <html>. Hai bối cảnh phổ biến:
Vite thuần — một đoạn script nhỏ, đọc localStorage + media query, tránh nhấp nháy (FOUC):
// đặt INLINE trong <head>, chạy TRƯỚC khi render — tránh "flash of wrong theme"
const stored = localStorage.theme;
const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && systemDark)) {
document.documentElement.classList.add('dark');
}
Next.js — chuẩn cộng đồng là next-themes, lo sẵn FOUC, đồng bộ tab, và lựa chọn “system”:
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
);
}
// components/theme-toggle.tsx — nút chuyển dùng chính Button của shadcn
'use client';
import { useTheme } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { setTheme, resolvedTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
>
<Sun className="size-5 dark:hidden" />
<Moon className="hidden size-5 dark:block" />
<span className="sr-only">Chuyển theme</span>
</Button>
);
}
attribute="class" là mảnh ghép then chốt — nó đặt class="dark" lên <html>, đúng selector .dark { … } của shadcn. Dùng resolvedTheme (không phải theme) để xử lý đúng khi đang ở chế độ "system".
Gotcha FOUC: nếu màu “nhấp nháy” sáng→tối lúc tải, nghĩa là class
.darkđược thêm sau lần paint đầu.next-themeschèn script chặn trước render để tránh; với Vite, đặt đoạn script ở mục trên inline trong<head>, không phải trong bundle React (chạy quá muộn).
10. Tùy biến component đã copy — sửa cva
Vì mã nguồn là của bạn, tùy biến chỉ là sửa một file. Trái tim của một component shadcn như Button là một khối cva (class-variance-authority, đã gặp ở Phần 5) — một bản đồ từ “tên variant” → “chuỗi class”. Đây là buttonVariants v4 (rút gọn):
// components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md " +
"text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 " +
"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3",
lg: "h-10 rounded-md px-6",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
Để ý cách bg-primary/90 dùng cú pháp opacity — đây là lý do hover “tối đi nhẹ” tự hoạt động ở cả hai theme, vì nó chỉ giảm alpha của token primary hiện hành.
Ba nước đi tùy biến phổ biến:
(a) Thêm variant mới — thêm một key vào map variant. Không đụng nơi nào đang dùng:
variant: {
// …các variant cũ…
success: "bg-emerald-600 text-white shadow-xs hover:bg-emerald-600/90",
brand: "bg-primary text-primary-foreground hover:opacity-90 " +
"bg-gradient-to-r from-primary to-accent",
}
<Button variant="success">Đã lưu</Button> {/* TypeScript tự gợi ý "success" */}
Vì VariantProps<typeof buttonVariants> suy kiểu từ chính cva, prop variant="success" được type-check miễn phí — gõ sai tên là báo lỗi compile.
(b) Override theo lần dùng — className vẫn merge an toàn qua cn (tailwind-merge, Phần 5), nên class sau thắng đúng:
<Button className="w-full rounded-full">Nút pill toàn chiều rộng</Button>
(c) Sửa thẳng markup — xóa variant không dùng, thêm slot icon, đổi default. Là file của bạn; sửa không sợ hỏng update vì shadcn không “patch ngược” code đã sinh.
11. Recipe — dựng brand theme, success variant, dashboard
Recipe 1 — Brand theme từ một màu. Chọn một hue brand (ví dụ tím-xanh 260°), rồi đổi --primary ở cả hai theme. Mọi nút, badge, switch, focus ring đi theo:
:root {
--primary: oklch(0.62 0.19 260);
--primary-foreground: oklch(0.98 0 0);
--ring: oklch(0.62 0.19 260); /* khớp ring với brand */
}
.dark {
--primary: oklch(0.72 0.17 260); /* sáng hơn cho nền tối */
--primary-foreground: oklch(0.18 0 0);
--ring: oklch(0.72 0.17 260);
}
Recipe 2 — Nút “success” có token riêng (theme đúng cả 2 mode). Thay vì hardcode emerald, làm nó theme-aware: thêm token ở cả hai mode, nối qua @theme inline, rồi dùng trong cva:
:root { --success: oklch(0.65 0.17 145); --success-foreground: oklch(0.98 0 0); }
.dark { --success: oklch(0.70 0.15 145); --success-foreground: oklch(0.16 0 0); }
@theme inline {
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
}
// giờ variant dùng token, tự đúng ở dark mode
success: "bg-success text-success-foreground shadow-xs hover:bg-success/90",
Recipe 3 — Khung dashboard chỉ bằng token ngữ nghĩa. Không một màu thô nào — toàn bộ tự đổi theme:
<div className="flex min-h-screen bg-background text-foreground">
<aside className="w-64 border-r border-sidebar-border bg-sidebar p-4">
<nav className="space-y-1">
<a className="block rounded-md px-3 py-2 bg-sidebar-primary text-sidebar-primary-foreground">
Tổng quan
</a>
<a className="block rounded-md px-3 py-2 hover:bg-sidebar-accent">Báo cáo</a>
</nav>
</aside>
<main className="flex-1 p-6">
<div className="rounded-lg border border-border bg-card p-6 text-card-foreground shadow-sm">
<h2 className="text-lg font-semibold">Doanh thu</h2>
<p className="mt-1 text-sm text-muted-foreground">30 ngày gần nhất</p>
</div>
</main>
</div>
Bật .dark lên — không sửa một dòng nào — và cả dashboard đổi sang tối, tương phản vẫn đúng. Đó là phần thưởng của token ngữ nghĩa.
12. Registry — chia sẻ component của bạn
Lệnh add của CLI kéo được từ bất kỳ URL nào, không chỉ catalog của shadcn. Đó là hệ registry — và là cách một tổ chức phân phối design system nội bộ trên nền shadcn:
# cài component từ registry bên thứ ba (hoặc của chính bạn)
npx shadcn@latest add https://your-design-system.com/r/fancy-card.json
Một mục registry là JSON mô tả: tên, file, và dependency cần kéo theo:
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "fancy-card",
"type": "registry:component",
"dependencies": ["@radix-ui/react-slot"],
"registryDependencies": ["button", "card"],
"files": [
{ "path": "components/ui/fancy-card.tsx", "type": "registry:component" }
]
}
registryDependencies cho phép một component dựa trên các component shadcn khác — CLI tự cài luôn button và card nếu thiếu. Cùng workflow add quen thuộc, nhưng component là của bạn, theme cũng theo token của bạn.
13. Gotchas thường gặp khi theming
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Đổi --primary mà màu không đổi | Sửa nhầm @theme thường thay vì khối :root | Token giá trị nằm ở :root/.dark; chỉ @theme inline là cầu nối |
bg-success không tồn tại | Quên dòng --color-success trong @theme inline | Mỗi token mới cần một dòng --color-* để Tailwind sinh utility |
| Dark mode “chết”, màu kẹt sáng | Dùng @theme (không inline) → giá trị bị đóng băng lúc build | Đổi sang @theme inline cho mọi token trỏ var() |
| Một theme đẹp, theme kia vỡ màu | Thêm token chỉ ở :root, quên .dark | Luôn định nghĩa token mới ở cả hai khối |
| Chữ chìm trên nền nhấn | Dùng text-white/text-black cứng thay vì *-foreground | Theo hợp đồng cặp bg-x + text-x-foreground |
| Màu nhấp nháy lúc tải trang | Class .dark thêm sau paint đầu (FOUC) | Script chặn inline trong <head> / dùng next-themes |
oklch(...) không hiện màu | Sai cú pháp (thiếu khoảng trắng, dùng dấu phẩy) | Đúng là oklch(L C H) cách nhau bằng space, alpha sau / |
Sửa ui/button.tsx rồi update mất | Hiểu nhầm shadcn “đồng bộ” code | shadcn không patch ngược — code đã copy là của bạn, an toàn |
Đổi style trong components.json không có tác dụng | style chỉ áp lúc sinh component | Đã sinh rồi thì sửa token/class trực tiếp |
Hai gotcha “quên
.dark” và “dùng@themethay vìinline” chiếm phần lớn lỗi theming. Khi nghi ngờ: mở DevTools → inspect element → xem.bg-primarycó phảivar(--primary)không. Nếu là giá trịoklch(...)cứng → bạn quêninline.
14. Bài tập
1. Vì sao component shadcn luôn ghép bg-primary với text-primary-foreground thay vì text-white?
Lời giải
Token --primary-foreground được chỉnh để tương phản tốt trên --primary ở cả hai theme — ở .dark cả hai đảo lại nên vẫn rõ chữ. text-white cứng sẽ chìm khi primary chuyển sáng (dark mode). Đó là hợp đồng *-foreground.
2. Bạn muốn mọi góc trên toàn app tròn hơn hẳn. Đổi đúng một chỗ nào?
Lời giải
Token --radius trong :root. Cả thang --radius-sm/md/lg/xl suy ra từ nó bằng calc(), nên một số định hình lại mọi rounded-*.
3. Làm sao thêm variant brand vào Button mà không đụng bất kỳ nơi nào đang dùng?
Lời giải
Thêm một key brand vào map variant của buttonVariants trong components/ui/button.tsx. Nhờ VariantProps<typeof buttonVariants>, variant="brand" được type-check tự động, và mọi <Button> cũ không đổi.
4. Bạn thêm --success vào :root và dùng bg-success, nhưng Tailwind báo class không tồn tại. Thiếu gì?
Lời giải
Thiếu cầu nối trong @theme inline: phải thêm --color-success: var(--success);. Khối :root chỉ định nghĩa giá trị; Tailwind chỉ sinh utility bg-success khi có token --color-success tương ứng. (Và nhớ định nghĩa --success ở cả .dark nữa.)
5. Trong oklch(0.62 0.19 260), mỗi con số nghĩa là gì? Đổi gì để được cùng màu nhưng sáng hơn cho dark mode?
Lời giải
0.62 = lightness (sáng 62%), 0.19 = chroma (độ rực), 260 = hue (sắc, độ). Để cùng màu nhưng sáng hơn, tăng L và giữ nguyên C/H — ví dụ oklch(0.72 0.19 260). Đây là sức mạnh của OKLCH: tạo dải sáng/tối chỉ bằng cách kéo một trục.
6. Mở generator ở mục 6, chuyển light↔dark và xác nhận cùng markup component vẫn đọc được. Giải thích cơ chế.
Lời giải
Class .dark định nghĩa lại toàn bộ token (--primary, --primary-foreground…) với giá trị đảo sáng. Component chỉ đọc tên token qua @theme inline (live var()), nên khi .dark bật, mọi utility tự trỏ giá trị mới — không component nào phải biết theme đang là gì. Hợp đồng *-foreground đảm bảo tương phản giữ đúng ở cả hai.
Điểm chính
- Theme shadcn là ba tầng:
:root/.darkchứa giá trị (token ngữ nghĩa),@theme inlinelà cầu nối tên → utility Tailwind, và component chỉ gõ utility — không bao giờ màu thô. - Bộ token đầy đủ (
--background,--card,--popover,--primary,--secondary,--muted,--accent,--destructive,--border,--input,--ring,--radius,--chart-*,--sidebar-*) là khái niệm — màu thật chỉ là chi tiết, đổi ở một nơi. - shadcn v4 dùng OKLCH
(L C H)vì đồng đều theo cảm nhận, dễ tạo dải sáng/tối (chỉ kéoL), và phủ gam màu rộng. @theme inline(không phải@themethường) là bắt buộc để dark mode “live” — token trỏvar()đổi runtime.- Hợp đồng
bg-x↔text-x-foregroundgiữ tương phản tự đúng ở cả hai theme; thấybg-*thiếu*-foregroundlà cờ đỏ. - Một token
--radiusđịnh hình mọi góc; style (default/new-york) chỉ áp lúc sinh code. - Tùy biến = sửa
cvatrongui/: thêm variant được type-check miễn phí, override quaclassNamemerge an toàn bằngcn. Nhớ định nghĩa token mới ở cả:rootvà.dark. - Registry cho
addcài từ bất kỳ URL nào — phân phối design system của bạn theo cùng workflow.
Tiếp theo
Phần 11 — Form: phần UI khó nhất làm cho đúng — react-hook-form + zod + các component Form của shadcn cho form type-safe, dễ tiếp cận, có validate với re-render tối thiểu.