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.

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: **-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-usageclassName still merges via cn (Part 5) {Override theo lần dùngclassName 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 buttonVariantsvariant 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; .dark redefines the same tokens {Theme shadcn là biến CSS ngữ nghĩa; .dark định nghĩa lại cùng token}.
  • Always pair bg-x with text-x-foreground for guaranteed contrast across themes {Luôn ghép bg-x với text-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 add install from any URL — distribute your own design system the same way {Registry cho phép add cà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}.