jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 địnhNên nằm ở đâuVí dụ
Màu nền/chữ/primary/destructivetoken shadcn trong :root/.dark--primary, --background, --destructive
Màu riêng cho chart/sidebartoken domain đã có sẵn--chart-1, --sidebar-accent
Variant component mớicva của component đã copybuttonVariants.variant.success
Ngoại lệ một mànclassName tại nơi dùngclassName="lg:max-w-3xl"
Giá trị lặp lại nhiều nơitoken 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.

TokenUtility tương ứngVai trò
--backgroundbg-backgroundNền trang gốc (body)
--foregroundtext-foregroundChữ mặc định trên nền gốc
--cardbg-cardNền của bề mặt nổi (Card, panel)
--card-foregroundtext-card-foregroundChữ trên card
--popoverbg-popoverNền lớp nổi tạm (Dropdown, Tooltip, Popover)
--popover-foregroundtext-popover-foregroundChữ trong popover
--primarybg-primaryMàu hành động chính (nút Submit, link nhấn)
--primary-foregroundtext-primary-foregroundChữ trên nền primary
--secondarybg-secondaryHành động phụ, ít nổi hơn
--secondary-foregroundtext-secondary-foregroundChữ trên nền secondary
--mutedbg-mutedNền trầm cho vùng phụ (skeleton, hàng disabled)
--muted-foregroundtext-muted-foregroundChữ phụ, caption, placeholder
--accentbg-accentNền nhấn nhẹ khi hover item (menu, list)
--accent-foregroundtext-accent-foregroundChữ trên nền accent
--destructivebg-destructiveHành động nguy hiểm (xóa), lỗi
--destructive-foreground*text-destructive-foregroundChữ trên nền destructive (xem ghi chú v4)
--borderborder-borderMàu viền mặc định mọi nơi
--inputborder-inputViền của ô input/select/textarea
--ringring-ringVò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-5fill-chart-15 màu cho biểu đồ (Recharts)
--sidebarbg-sidebarNền riêng cho khối Sidebar
--sidebar-foregroundtext-sidebar-foregroundChữ trong sidebar
--sidebar-primarybg-sidebar-primaryItem active trong sidebar
--sidebar-primary-foregroundChữ trên item active
--sidebar-accentbg-sidebar-accentHover item sidebar
--sidebar-accent-foregroundChữ khi hover
--sidebar-borderborder-sidebar-borderViền trong sidebar
--sidebar-ringring-sidebar-ringFocus ring trong sidebar

* Ở v4 default, một số nút destructive dùng thẳng text-white thay 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-foreground nế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.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.2050.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ùng oklch(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ả :root .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ĩaKhoảng giá trị
L — LightnessĐộ sáng cảm nhận0 (đen) → 1 (trắng)
C — ChromaĐộ rực/bão hòa0 (xám) → ~0.4 (rực nhất)
H — HueSắc (góc bánh xe màu)0360 (độ)

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:

  1. Đồ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ế để L khớp với cảm nhận mắt người — nên một thang L đều tay cho ra một thang màu thấy cũng đều.
  2. Tạo dải màu cực dễ. Muốn một thang brand sáng→tối? Giữ CH, chỉ kéo L. Trong hex bạn phải dò từng giá trị.
  3. 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 @theme thườ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-foregroundbg-primary text-whitetext-white có thể chìm khi primary sáng (dark mode)
bg-muted text-muted-foregroundbg-muted text-foregroundtext-foreground quá đậm, mất sắc thái “phụ”
bg-card text-card-foregroundbg-card text-blackĐen cứng vỡ ở dark mode

Quy tắc thực chiến: thấy bg-*khôngtext-*-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 --radius quá 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 khi rounded-smrounded-md trô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:

defaultnew-york
Cảm giác chungMềm, nhiều khoảng thởGọn, đặc, sắc nét hơn
Bóng (shadow)Rõ hơn (shadow-smmd)Tinh giản, ít bóng
Cỡ & spacingRộng rãi hơnChặt hơn (nút thấp hơn chút)
Icon mặc địnhlucide-reactlucide-react (đậm/nét khác)
Khuyến nghị hiện tạishadcn đặ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-themes chè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" */}

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ùngclassName 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 buttoncard 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ứngNguyên nhânCách xử lý
Đổi --primary mà màu không đổiSửa nhầm @theme thường thay vì khối :rootToken giá trị nằm ở :root/.dark; chỉ @theme inline là cầu nối
bg-success không tồn tạiQuên dòng --color-success trong @theme inlineMỗi token mới cần một dòng --color-* để Tailwind sinh utility
Dark mode “chết”, màu kẹt sángDù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àuThêm token chỉ ở :root, quên .darkLuôn định nghĩa token mới ở cả hai khối
Chữ chìm trên nền nhấnDùng text-white/text-black cứng thay vì *-foregroundTheo hợp đồng cặp bg-x + text-x-foreground
Màu nhấp nháy lúc tải trangClass .dark thêm sau paint đầu (FOUC)Script chặn inline trong <head> / dùng next-themes
oklch(...) không hiện màuSai 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ấtHiểu nhầm shadcn “đồng bộ” codeshadcn 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ụngstyle 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 @theme thay vì inline” chiếm phần lớn lỗi theming. Khi nghi ngờ: mở DevTools → inspect element → xem .bg-primary có phải var(--primary) không. Nếu là giá trị oklch(...) cứng → bạn quên inline.


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 --primarycả 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/.dark chứa giá trị (token ngữ nghĩa), @theme inlinecầ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éo L), và phủ gam màu rộng.
  • @theme inline (không phải @theme thường) là bắt buộc để dark mode “live” — token trỏ var() đổi runtime.
  • Hợp đồng bg-xtext-x-foreground giữ tương phản tự đúng ở cả hai theme; thấy bg-* thiếu *-foreground là 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 cva trong ui/: thêm variant được type-check miễn phí, override qua className merge an toàn bằng cn. Nhớ định nghĩa token mới ở cả :root .dark.
  • Registry cho add cà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.