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.
shadcn components look good by default, but the whole point of owning the code is making them look like yours {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}. Theming shadcn is just the semantic-token pattern from Part 3, applied systematically {Theme shadcn chỉ là mẫu token ngữ nghĩa từ Phần 3, áp dụng có hệ thống}. Master this and you can re-skin an entire app by editing a handful of variables {Nắm chắc và bạn re-skin cả app bằng cách sửa vài biến}.
1. The theme is just CSS variables {Theme chỉ là biến CSS}
init added a block of semantic color tokens to your stylesheet {init đã thêm một khối token màu ngữ nghĩa vào stylesheet}. Every shadcn component references these names — never raw colors {Mọi component shadcn tham chiếu các tên này — không bao giờ màu thô}:
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--border: oklch(0.922 0 0);
--destructive: oklch(0.577 0.245 27);
--ring: oklch(0.708 0 0);
/* …card, popover, accent, input… */
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
/* …every token redefined for dark… */
}
Modern shadcn ships colors in OKLCH (perceptually uniform), but the mechanism is identical to any hex/HSL setup — it’s still just variables redefined under
.dark{shadcn hiện đại dùng màu OKLCH (đồng đều theo cảm nhận), nhưng cơ chế giống hệt mọi setup hex/HSL — vẫn chỉ là biến được định nghĩa lại dưới.dark}.
The tokens are wired to Tailwind via @theme inline so utilities like bg-primary, text-muted-foreground, border-input exist {Token được nối với Tailwind qua @theme inline để các utility như bg-primary, text-muted-foreground, border-input tồn tại}:
@theme inline {
--color-background: var(--background);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
/* … */
}
2. The pairing rule: * and *-foreground {Quy tắc cặp đôi: * và *-foreground}
The naming is a contract {Cách đặt tên là một hợp đồng}: a background token --primary always has a matching text token --primary-foreground guaranteed to be readable on it {một token nền --primary luôn có token chữ --primary-foreground khớp, đảm bảo đọc được trên đó}. So you write pairs {Nên bạn viết theo cặp}:
<div class="bg-primary text-primary-foreground">always legible</div>
<div class="bg-destructive text-white">…</div>
<div class="bg-muted text-muted-foreground">…</div>
Follow this rule and contrast stays correct across both themes automatically {Theo quy tắc này thì độ tương phản luôn đúng ở cả hai theme một cách tự động}.
3. Brand it — change a few variables {Gắn thương hiệu — đổi vài biến}
To make primary your brand color, you change the token in one place and every Button, Badge, Switch, focus ring follows {Để biến primary thành màu brand, bạn đổi token ở một nơi và mọi Button, Badge, Switch, focus ring đi theo}:
:root { --primary: oklch(0.62 0.19 260); --primary-foreground: oklch(0.98 0 0); }
.dark { --primary: oklch(0.72 0.17 260); --primary-foreground: oklch(0.18 0 0); }
Pick a color and radius and watch the components re-skin live, with the exact CSS to paste {Chọn màu và radius rồi xem component re-skin trực tiếp, kèm CSS chính xác để paste}:
The radius token deserves a note: shadcn derives rounded-sm/md/lg/xl from the single --radius value, so one number reshapes every corner in the system {Token radius đáng nhắc: shadcn suy ra rounded-sm/md/lg/xl từ một giá trị --radius, nên một con số định hình lại mọi góc bo trong hệ thống}.
4. Dark mode wiring {Nối dây dark mode}
The .dark block exists; you just need to toggle the class {Khối .dark đã có; bạn chỉ cần bật class}. In a plain Vite app, the snippet from Part 3 works {Trong app Vite thuần, đoạn ở Phần 3 hoạt động}. In Next.js, the community standard is next-themes {Trong Next.js, chuẩn cộng đồng là next-themes}:
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
);
}
// a theme toggle
import { useTheme } from 'next-themes';
const { setTheme, theme } = useTheme();
<Button variant="ghost" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle theme
</Button>
attribute="class" is what makes it put .dark on <html>, matching shadcn’s .dark { … } block {attribute="class" là thứ làm nó đặt .dark lên <html>, khớp khối .dark { … } của shadcn}.
5. Customizing a copied component {Tùy biến một component đã copy}
Because the source is yours, customization is just editing a file {Vì mã nguồn là của bạn, tùy biến chỉ là sửa một file}. Three common moves {Ba nước đi phổ biến}:
Add a variant — extend the component’s cva {Thêm variant — mở rộng cva của component}:
// components/ui/button.tsx
variant: {
// …existing…
brand: 'bg-gradient-to-r from-indigo-500 to-fuchsia-500 text-white hover:opacity-90',
}
Override per-usage — className still merges via cn (Part 5) {Override theo lần dùng — className vẫn merge qua cn (Phần 5)}:
<Button className="w-full rounded-full">Full width pill</Button>
Change the markup — delete a variant you’ll never use, add an icon slot, rename a prop {Đổi markup — xóa variant không dùng, thêm slot icon, đổi tên prop}. It’s your file; nothing breaks elsewhere {Là file của bạn; không hỏng chỗ khác}.
6. The registry — share your own {Registry — chia sẻ component của bạn}
The CLI’s add can pull from any URL, not just shadcn’s catalog {Lệnh add của CLI kéo được từ bất kỳ URL nào, không chỉ catalog của shadcn}. That’s the registry system {Đó là hệ registry}:
# install a component from a third-party (or your own) registry
npx shadcn@latest add https://your-design-system.com/r/fancy-card.json
You can publish your own components as a registry so your whole org installs them the same way {Bạn có thể publish component của mình thành một registry để cả tổ chức cài giống nhau}. A registry item is a JSON descriptor listing files + dependencies {Một mục registry là JSON mô tả liệt kê file + dependency}:
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "fancy-card",
"type": "registry:component",
"dependencies": ["@radix-ui/react-slot"],
"files": [{ "path": "components/ui/fancy-card.tsx", "type": "registry:component" }]
}
This is how internal design systems get distributed on top of shadcn — same add workflow, your components {Đây là cách design system nội bộ được phân phối trên nền shadcn — cùng workflow add, component của bạn}.
7. Exercises {Bài tập}
1. Why do shadcn components always pair bg-primary with text-primary-foreground? {Vì sao component shadcn luôn ghép bg-primary với text-primary-foreground?}
Solution {Lời giải}
The *-foreground token is guaranteed readable on its matching background in both themes, so contrast stays correct automatically {Token *-foreground đảm bảo đọc được trên nền khớp của nó ở cả hai theme, nên tương phản luôn đúng tự động}.
2. You want every corner slightly rounder app-wide. What do you change? {Bạn muốn mọi góc tròn hơn chút trên toàn app. Đổi gì?}
Solution {Lời giải}
The single --radius token — shadcn derives all rounded-* sizes from it {Một token --radius — shadcn suy ra mọi cỡ rounded-* từ nó}.
3. How do you add a brand variant to the Button without touching any usage site? {Làm sao thêm variant brand vào Button mà không đụng nơi dùng?}
Solution {Lời giải}
Add a brand key to buttonVariants’ variant map in components/ui/button.tsx {Thêm key brand vào map variant của buttonVariants trong components/ui/button.tsx}.
Stretch {Nâng cao}: in the generator, switch between light and dark and confirm the same component markup stays legible — that’s the *-foreground contract working {trong generator, chuyển light/dark và xác nhận cùng markup component vẫn đọc được — đó là hợp đồng *-foreground đang chạy}.
Key takeaways {Điểm chính}
- shadcn theming is semantic CSS variables;
.darkredefines the same tokens {Theme shadcn là biến CSS ngữ nghĩa;.darkđịnh nghĩa lại cùng token}. - Always pair
bg-xwithtext-x-foregroundfor guaranteed contrast across themes {Luôn ghépbg-xvớitext-x-foregroundđể đảm bảo tương phản qua các theme}. - Brand by editing a few tokens (
--primary,--radius); customize components by editing their source {Gắn brand bằng cách sửa vài token; tùy biến component bằng sửa mã nguồn của chúng}. - The registry lets
addinstall from any URL — distribute your own design system the same way {Registry cho phépaddcài từ bất kỳ URL nào — phân phối design system của bạn theo cùng cách}.
Next up {Tiếp theo}
Part 11 — Forms: the hardest UI done right — react-hook-form + zod + shadcn’s Form components for type-safe, accessible, validated forms with minimal re-renders {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}.