Tailwind, Radix & shadcn/ui · Part 9 — shadcn/ui: Philosophy & Setup
Why shadcn is "not a component library", how the CLI and components.json work, and adding your first components — the moment Tailwind, Radix and cva/cn click into one workflow. With a live component gallery.
Mọi thứ tới giờ đều dẫn tới đây. Bạn biết Tailwind (style), biết Radix (hành vi headless dễ tiếp cận — Phần 7 & 8), biết cva + cn (variant + merge class — Phần 5). shadcn/ui là kết quả khi ai đó ráp ba thứ đó lại, có gu, cho ~50 component phổ biến — rồi đưa bạn mã nguồn thay vì một package.
Khác biệt lớn nhất so với mọi thư viện UI bạn từng dùng: không có gì để “cài và import”. shadcn là một CLI chép code vào repo của bạn. Sau đó bạn không còn là “người dùng thư viện” nữa — bạn là chủ sở hữu mã nguồn. Phần này mổ xẻ tận gốc triết lý đó, từng dòng output của CLI, từng field của components.json, và khép lại bằng một recipe init-tới-chạy hoàn chỉnh. Đọc xong, bạn không cần mở ui.shadcn.com nữa.
Ownership workflow
Vì shadcn copy code vào repo, quy trình làm việc cũng khác thư viện npm:
| Việc | Cách nghĩ đúng |
|---|---|
| Thêm component | CLI tạo bản baseline tốt, không phải chân lý cuối cùng |
| Sửa style | Sửa trực tiếp file trong components/ui, commit như code của bạn |
| Thêm variant | Sửa cva trong component, không override từ ngoài |
| Nâng cấp component | Xem diff registry mới, merge có chủ đích |
| Chuẩn hóa team | Viết convention nhỏ: token, variant name, asChild, import path |
Đừng xem folder components/ui như vendor code cấm đụng. Nó là một phần design system của bạn. Nhưng cũng đừng sửa tùy hứng mỗi màn một kiểu; mỗi thay đổi nên hướng tới một API tốt hơn cho toàn app.
1. “Open code” — triết lý cốt lõi, không phải thư viện component
Thư viện UI truyền thống (MUI, Ant Design, Chakra) là package npm. Vòng đời của nó như sau:
npm install @mui/material
↓
import { Button } from '@mui/material'
↓
<Button> sống trong node_modules/ ← bạn KHÔNG sửa được
↓
Muốn đổi style? → override từ ngoài (theme API, sx, !important, styled())
↓
Nâng cấp? → npm update → có thể vỡ giao diện, kẹt version
Component là hộp đen (black box): bạn cấu hình từ bên ngoài và vật lộn khi cần đổi một chi tiết mà API không cho phép. Đây là mô hình “closed code”.
shadcn/ui đảo ngược hoàn toàn — gọi là “open code”:
Bạn không cài component — bạn copy mã nguồn của chúng vào project của bạn.
Một CLI thả file .tsx thật vào src/components/ui/. Kể từ giây đó, nó là file của bạn: sửa markup, xóa một variant không xài, thêm prop, đổi class Tailwind, refactor đặt tên — không override, không !important, không kẹt version. Component không nằm trong node_modules mà nằm thẳng trong source bạn commit lên Git.
Bốn trụ cột của triết lý open code
| Trụ cột | Nghĩa là | Vì sao quan trọng |
|---|---|---|
| Open Code | Code component nằm trong repo bạn, sửa trực tiếp | AI/đồng đội đọc & sửa được; không bí ẩn |
| Composition | Mọi component dùng chung interface có thể đoán trước (className, asChild, forward props) | Học một cái → biết cách dùng cả bộ |
| Distribution | Một registry (schema + CLI) để phát hành & chia sẻ component qua URL | Bạn cũng tự phát hành component của mình được (Phần 10) |
| Beautiful defaults | Style mặc định đẹp, trung tính, ráp lại nhìn liền mạch | Đẹp ngay nhưng không khóa bạn vào một “look” |
Đánh đổi — nói thật cả hai mặt
| shadcn (copy-in / open code) | MUI · Ant · Chakra (package) | |
|---|---|---|
| Sở hữu | Bạn sở hữu & sửa được từng dòng | Chỉ restyle từ ngoài |
| Runtime dep | Không có dep “thư viện UI”; chỉ Radix + vài util nhỏ | Một import lớn, ít code trong repo |
| Hộp đen | Không — mọi thứ lộ thiên trong components/ui/ | Có — logic ẩn trong node_modules |
| Cập nhật | Bạn áp thủ công (diff & merge) | npm update tự động |
| Đường cong học | Dùng Tailwind + Radix bạn đã biết | Phải học hệ styling riêng của thư viện |
| Bundle | Chỉ ship component bạn thực dùng | Tree-shaking tùy thư viện |
| Khi cần sửa sâu | Mở file, sửa, xong | Có thể bất khả thi nếu API không cho |
Chốt triết lý: thư viện truyền thống tối ưu cho “ít code trong repo, cập nhật dễ”. shadcn tối ưu cho “toàn quyền kiểm soát, không hộp đen”. Với team đã sống trong Tailwind, vế thứ hai gần như luôn thắng — vì bạn restyle bằng đúng công cụ đang dùng hằng ngày, không học thêm gì.
Xem ngay bộ component bạn sẽ có:
2. Bốn mảnh ghép — shadcn = Radix + Tailwind + cva + cn
shadcn không phát minh ra gì mới. Nó là cách ráp bốn thứ bạn đã học thành một workflow. Đây là sơ đồ giải phẫu của bất kỳ component shadcn nào:
┌─────────────────────────────────────────────────────────┐
│ Một component shadcn │
│ │
│ Radix primitive → hành vi + a11y (Phần 7 & 8) │
│ Tailwind utility → vẻ ngoài (Phần 2 & 4) │
│ cva() → bảng variant (Phần 5) │
│ cn() → trộn class an toàn (Phần 5) │
│ token ngữ nghĩa → bg-primary, … (Phần 3) │
└─────────────────────────────────────────────────────────┘
| Mảnh | Đến từ | Vai trò trong shadcn | Học ở |
|---|---|---|---|
| Radix UI | @radix-ui/react-* | Cung cấp hành vi headless: focus trap, keyboard nav, ARIA, asChild/Slot | Phần 7 (primitives), Phần 8 (composition, asChild, state) |
| Tailwind | utility class | Toàn bộ phần nhìn thấy: spacing, màu, radius, shadow, state hover:/focus-visible: | Phần 2 (core), Phần 4 (variants & states) |
cva | class-variance-authority | Khai báo bảng variant/size + defaultVariants thành type-safe props | Phần 5 |
cn | clsx + tailwind-merge | Gộp class nội bộ với className người dùng truyền vào, giải xung đột class trùng | Phần 5 |
Nói cách khác: Radix lo “nó hoạt động đúng & dễ tiếp cận”, Tailwind lo “nó trông thế nào”, cva lo “có mấy biến thể”, cn lo “trộn lại không cãi nhau”. shadcn là người biên đạo ghép bốn vũ công này. Khi bạn mở một file shadcn (mục 8), bạn sẽ thấy chính xác bốn mảnh này, không hơn.
3. Điều kiện cần — stack giả định
shadcn giả định đúng stack ta đã dựng xuyên series. Trước khi init, hãy chắc bốn thứ:
| Yêu cầu | Vì sao | Kiểm tra nhanh |
|---|---|---|
| React (Vite, Next.js, Astro, Remix, Laravel…) | Component là .tsx/.jsx React | có react trong package.json |
| Tailwind đã cấu hình | Mọi style là utility Tailwind | @import "tailwindcss"; có trong CSS (v4) |
| TypeScript (khuyến nghị) | Component ship dạng .tsx; CLI vẫn hỗ trợ .jsx | có tsconfig.json |
Path alias @/* | Component import nhau qua @/components/... và @/lib/utils | thấy paths trong tsconfig.json |
Path alias bắt buộc phải đúng, vì mọi file shadcn import lẫn nhau bằng nó:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
}
}
Với Vite, alias trong tsconfig.json thôi chưa đủ — bundler cũng cần biết. Thêm vào vite.config.ts:
// vite.config.ts
import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
});
Bẫy #1, gặp nhiều nhất: quên đăng ký alias ở
vite.config.ts. TypeScript không báo lỗi (vìtsconfigđã cópaths), nhưng lúc chạy Vite némFailed to resolve import "@/lib/utils". Alias phải khai báo ở cả hai nơi.
4. init — chạy một lần, đi qua từng câu hỏi
npx shadcn@latest init
Lưu ý package name: dùng
shadcn(không phảishadcn-ui— package cũ đã ngừng). Luôn@latestđể lấy CLI mới nhất.
CLI sẽ hỏi vài câu. Đây là từng prompt và ý nghĩa, để bạn không bấm bừa Enter:
| Prompt | Lựa chọn | Nó quyết định gì |
|---|---|---|
| Which style? | new-york hoặc default | Bộ style mặc định (new-york gọn, sắc hơn). Ghi vào style |
| Base color? | zinc · slate · stone · gray · neutral | Tông xám nền cho token theme. Ghi vào tailwind.baseColor |
| CSS variables for theming? | yes (khuyến nghị) | yes → token kiểu bg-primary; no → class Tailwind trực tiếp. Ghi vào tailwind.cssVariables |
Sau khi trả lời, init làm ba việc cụ thể:
npx shadcn@latest init
│
├─► ghi components.json (manifest — mục 5)
│
├─► sửa src/index.css (thêm token CSS theme: --primary, --border… — Phần 3)
│
└─► tạo src/lib/utils.ts (helper cn() — Phần 5)
File lib/utils.ts sinh ra chính xác là cái bạn viết tay ở Phần 5:
// src/lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Và stylesheet được chèn khối token ngữ nghĩa (rút gọn — Phần 3 & 10 đào sâu):
/* src/index.css — init chèn thêm khối này */
@import "tailwindcss";
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
/* …và nhiều token khác */
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* …bản dark của từng token */
}
Tailwind v4 note: ở v4, init dùng
@theme inlineđể ánh xạ các biến--primarynày thành utilitybg-primary, và màu mặc định là OKLCH thay vì HSL (như v3). Nếu bạn thấyoklch(...)thay vìhsl(...), đó là dấu hiệu bạn đang ở luồng v4 — đúng và mong muốn.
5. components.json — giải thích từng field
Đây là manifest của project: mọi lệnh add sau đọc nó để biết đặt file ở đâu, theo quy ước nào. Hiểu file này là hiểu cách CLI “nghĩ”.
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"utils": "@/lib/utils",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
| Field | Kiểu | Ý nghĩa | Ghi chú |
|---|---|---|---|
$schema | URL | Schema để editor gợi ý & validate file | Để IDE autocomplete đúng |
style | "new-york" | "default" | Bộ style mặc định CLI dùng khi copy component | Đặt lúc init, đổi sau khá phiền |
rsc | boolean | React Server Components? Nếu true, CLI thêm "use client" vào component cần | true cho Next.js App Router; false cho Vite/SPA |
tsx | boolean | Sinh .tsx (true) hay .jsx (false) | false nếu project không dùng TS |
tailwind.config | path | Đường dẫn tailwind.config (v3) | Để rỗng "" ở Tailwind v4 — v4 không cần file config |
tailwind.css | path | File CSS chứa @import "tailwindcss" & token theme | CLI chèn biến theme vào đây |
tailwind.baseColor | string | Tông xám nền (zinc/slate/…) | Chỉ dùng lúc init để sinh token; đổi sau không hồi tố |
tailwind.cssVariables | boolean | true → token bg-primary; false → class màu trực tiếp | true để theme-able (khuyến nghị) |
tailwind.prefix | string | Prefix class Tailwind (vd tw-) | Để rỗng nếu không dùng prefix |
aliases.components | alias | Thư mục gốc cho component | Map tới @/components |
aliases.ui | alias | Nơi đặt component shadcn | @/components/ui |
aliases.utils | alias | Đường dẫn tới cn() | @/lib/utils — phải khớp nơi file utils thật nằm |
aliases.lib | alias | Thư mục lib chung | @/lib |
aliases.hooks | alias | Nơi đặt custom hooks (vd use-toast) | @/hooks |
iconLibrary | "lucide" | "radix" | Bộ icon component dùng | lucide mặc định; component import từ đây |
aliases.utilslà field hay gây lỗi nhất. Nếu bạn đểcn()ởsrc/utils/cn.tsnhưngcomponents.jsontrỏ@/lib/utils, thì mọi componentaddvề sẽimport { cn } from '@/lib/utils'— và vỡ. Giữ vị trí file utils khớp đúng với alias này (mục Gotchas).
6. Bố cục file sinh ra
Sau init rồi add vài component, cây thư mục trông như sau:
src/
├── index.css # @import "tailwindcss" + token theme (init chèn)
├── lib/
│ └── utils.ts # cn() ← init tạo
└── components/
└── ui/ # mọi component shadcn nằm ĐÂY (aliases.ui)
├── button.tsx # add button
├── card.tsx # add card
├── dialog.tsx # add dialog
├── input.tsx
├── label.tsx
└── form.tsx
Quy luật chỗ-nào-cái-gì:
| Đường dẫn | Chứa | Ai tạo |
|---|---|---|
components/ui/*.tsx | Component shadcn — file của bạn, sửa thoải mái | add |
lib/utils.ts | cn() và helper chung | init |
index.css (hoặc globals.css) | @import "tailwindcss" + token theme :root/.dark | init |
hooks/*.ts | Hook đi kèm vài component (vd use-toast) | add (khi cần) |
Điểm mấu chốt: không có thư mục node_modules/@shadcn/.... Toàn bộ “thư viện” của bạn là các file .tsx lộ thiên, commit vào Git, review trong PR như mọi code khác.
7. add — kéo component vào (và registry là gì)
npx shadcn@latest add button card input badge avatar alert dialog
Lệnh add làm bốn việc:
npx shadcn@latest add dialog
│
├─► fetch định nghĩa "dialog" từ REGISTRY (mặc định: ui.shadcn.com)
├─► copy mã nguồn vào components/ui/dialog.tsx
├─► tự cài dep Radix cần: npm i @radix-ui/react-dialog
└─► thêm cva / clsx / tailwind-merge nếu project chưa có
Chạy add không kèm tên sẽ hiện danh sách chọn tương tác:
npx shadcn@latest add # hiện menu chọn nhiều component
Registry là gì
Registry là một “nguồn” cung cấp định nghĩa component dưới dạng JSON theo schema chuẩn. Mặc định CLI trỏ tới registry chính thức của shadcn. Nhưng add cũng nhận URL trỏ tới bất kỳ registry tương thích nào:
# Thêm từ một registry/URL bên thứ ba (phải đúng schema shadcn)
npx shadcn@latest add https://example.com/r/fancy-button.json
# Thêm nhiều component từ registry chính thức
npx shadcn@latest add button dialog form
Registry (JSON theo schema)
│ { name, files[], dependencies[], registryDependencies[] }
▼
npx shadcn add <name | URL>
│
▼
Resolve → tải file + dep → ghi vào components/ui/
Cơ chế registry này chính là “Distribution” ở mục 1: nó cho phép bạn (và cộng đồng) tự phát hành component và cài bằng cùng một CLI — ta dựng registry riêng ở Phần 10.
Cập nhật & diff component
Vì component giờ là code của bạn, shadcn không tự npm update chúng. Bạn cập nhật có chủ đích:
# Xem khác biệt giữa bản local và bản mới nhất trên registry
npx shadcn@latest diff button
# Lấy lại bản mới nhất (sẽ GHI ĐÈ file hiện tại)
npx shadcn@latest add button --overwrite
| Lệnh | Tác dụng | Khi nào dùng |
|---|---|---|
diff <component> | In ra phần khác giữa local & upstream | Trước khi quyết định cập nhật |
add <component> --overwrite | Ghi đè file local bằng bản mới | Khi bạn chưa sửa file đó |
| (chép thủ công từ diff) | Merge tay phần upstream vào file đã sửa | Khi bạn đã tùy biến file |
Vì bạn sở hữu code, cập nhật là một thao tác merge, không phải
npm update. Nếu bạn đã sửabutton.tsx, đừng--overwritemù — chạydifftrước, rồi áp tay thay đổi upstream để không mất tùy biến.
8. Đọc button.tsx sinh ra — bạn nhận ra từng dòng
Đây là phần thưởng của cả series. Mở file CLI vừa tạo, và bạn thấy đúng bốn mảnh ở mục 2:
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot'; // Phần 8 — asChild
import { cva, type VariantProps } from 'class-variance-authority'; // Phần 5 — variants
import { cn } from '@/lib/utils'; // Phần 5 — merge class
const buttonVariants = cva(
// base: luôn áp — layout, typography, transition, state focus/disabled (Phần 2 & 4)
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium ' +
'transition-colors focus-visible:outline-none focus-visible:ring-2 ' +
'disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', // Phần 3 — token ngữ nghĩa
destructive: 'bg-destructive text-white hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent',
secondary: 'bg-secondary text-secondary-foreground 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', sm: 'h-8 px-3', lg: 'h-10 px-6', icon: 'size-9' },
},
defaultVariants: { variant: 'default', size: 'default' },
}
);
interface ButtonProps
extends React.ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button'; // Phần 8 — đổi thẻ render
return (
<Comp
className={cn(buttonVariants({ variant, size }), className)} // Phần 5 — base+variant rồi merge override
{...props}
/>
);
}
export { Button, buttonVariants };
Bóc từng dòng về phần đã dạy:
| Dòng / khái niệm | Là gì | Dạy ở |
|---|---|---|
import { Slot } + asChild ? Slot : 'button' | Đổi thẻ render để hợp nhất prop vào con (vd bọc <a>) | Phần 8 |
cva(base, { variants, defaultVariants }) | Bảng variant type-safe, sinh prop variant/size | Phần 5 |
cn(buttonVariants(...), className) | Gộp class nội bộ + className ngoài, giải xung đột nhờ tailwind-merge | Phần 5 |
bg-primary, text-primary-foreground | Token ngữ nghĩa từ biến CSS theme | Phần 3 |
hover:bg-primary/90, focus-visible:ring-2, disabled:opacity-50 | Variant state | Phần 4 |
inline-flex … rounded-md text-sm | Utility layout/typography cơ bản | Phần 2 |
VariantProps<typeof buttonVariants> | Suy ra kiểu prop từ bảng cva | Phần 5 |
Không có phép màu nào. shadcn chỉ là người ráp bốn mảnh bạn đã thành thạo — nên khi cần sửa, bạn sửa được, vì đây là code bạn đọc hiểu trọn vẹn.
9. Recipe thực tế — init một project rồi dựng form trong dialog
Mục tiêu: từ project Vite + React + Tailwind v4 trống, dựng nút “New project” mở Dialog chứa một Form có Input validate. Đi từ đầu tới chạy.
Bước 1 — init shadcn (giả định Tailwind v4 & alias đã có như mục 3):
npx shadcn@latest init
# Style: new-york · Base color: zinc · CSS variables: yes
Bước 2 — add các component cần:
npx shadcn@latest add button dialog form input label
add formkéo theoreact-hook-form,zod,@hookform/resolversvà componentForm*(bọc quanhreact-hook-form+ Radix Label).add dialogcài@radix-ui/react-dialog.
Bước 3 — ráp lại:
// src/components/CreateProjectDialog.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog, DialogTrigger, DialogContent, DialogHeader,
DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
} from '@/components/ui/form';
const schema = z.object({
name: z.string().min(2, 'Tên tối thiểu 2 ký tự'),
});
export function CreateProjectDialog() {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { name: '' },
});
function onSubmit(values: z.infer<typeof schema>) {
console.log('Tạo project:', values.name);
}
return (
<Dialog>
<DialogTrigger asChild>
<Button>New project</Button> {/* Phần 8 — asChild gắn hành vi vào Button của bạn */}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create project</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project name</FormLabel>
<FormControl>
<Input placeholder="acme-web" {...field} />
</FormControl>
<FormMessage /> {/* tự hiện lỗi zod, đã nối a11y */}
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit">Deploy</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
Để ý cách bốn mảnh xuất hiện tự nhiên: Dialog* là Radix (focus trap, ESC, ARIA — Phần 7/8), DialogTrigger asChild hợp nhất hành vi vào Button của bạn (Phần 8), Button/Input mang style Tailwind + cva/cn (Phần 2/4/5), FormMessage tự nối lỗi zod với input qua aria-describedby. Bạn không viết một dòng CSS nào và vẫn có dialog dễ tiếp cận, form validate đầy đủ.
10. Tailwind v4 + React 19 — những điểm cần biết
shadcn đã cập nhật cho hệ sinh thái mới. Vài điểm đáng ghi nhớ:
| Chủ đề | Trạng thái mới | Hệ quả |
|---|---|---|
| Tailwind v4 | tailwind.config để rỗng "" trong components.json; token định nghĩa trong CSS qua @theme/:root | Không có file tailwind.config.js để chỉnh — sửa token ngay trong CSS (Phần 3) |
| Màu OKLCH | Token theme dùng oklch(...) thay HSL | Màu đồng đều về thị giác hơn; copy theme cũ HSL cần chuyển đổi |
| React 19 | forwardRef phần lớn không còn cần — ref là prop thường | Component mới dùng function Button(props) thay React.forwardRef |
@radix-ui/react-* | Một số bản hợp nhất vào gói radix-ui đơn | add tự chọn import đúng — đừng tự thêm tay |
data-slot | Component mới gắn data-slot="..." cho mỗi phần | Tiện để target style theo phần con bằng CSS attribute selector |
Nâng cấp project shadcn cũ (v3 → v4): chạy
npx @tailwindcss/upgradecho Tailwind trước (đổi@tailwind→@import, di chuyển config sang@theme), rồinpx shadcn@latest difftừng component để áp các thay đổi cấu trúc (vd bỏforwardRef, thêmdata-slot). Đừng làm cả hai một lúc — tách bước cho dễ soát.
11. Gotchas thường gặp khi setup
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
Failed to resolve import "@/lib/utils" | Alias @ chỉ khai trong tsconfig, chưa khai ở vite.config.ts | Thêm resolve.alias trong vite.config.ts (mục 3) |
Component import { cn } bị đỏ | aliases.utils trong components.json không khớp vị trí file utils.ts thật | Sửa alias hoặc di chuyển file cho khớp |
| Mất hết tùy biến sau khi cập nhật | Chạy add <comp> --overwrite đè lên file bạn đã sửa | Dùng diff trước, merge tay; chỉ --overwrite khi file còn nguyên gốc |
npx shadcn-ui@latest báo lỗi/không tồn tại | Package cũ shadcn-ui đã ngừng | Dùng shadcn (bỏ -ui) |
Token bg-primary không ra màu | cssVariables: false lúc init, hoặc CSS theme chưa được import | Đặt cssVariables: true; chắc file CSS có token & được nạp ở entry |
Build lỗi sau khi đổi style/baseColor trong components.json | Hai field này chỉ dùng lúc init/add, không hồi tố token đã sinh | Đổi token thủ công trong CSS, hoặc init lại file mới |
add form thiếu validate | Chưa cài zod + @hookform/resolvers | add form thường tự cài; nếu không, cài tay ba package |
| RSC: lỗi “useState only works in Client Component” | rsc: true nhưng component tương tác thiếu "use client" | Chắc rsc:true để CLI tự chèn "use client"; hoặc thêm tay ở đầu file |
Hai lỗi alias (Vite chưa khai báo &
aliases.utilslệch) chiếm phần lớn sự cố “mới setup mà đỏ tùm lum”. Khi gặp import@/...không resolve, kiểm tra cả hai nơi alias:tsconfig.jsonvàvite.config.ts, rồi đối chiếualiases.utilsvới đường dẫn filecnthật.
12. Bài tập
1. Một câu: shadcn khác MUI căn bản ở chỗ nào?
Lời giải
shadcn copy mã nguồn component (open code) sửa được thẳng vào repo của bạn; MUI ship component dưới dạng package npm bạn import từ node_modules và chỉ cấu hình/restyle từ ngoài.
2. npx shadcn@latest init tạo/sửa ba thứ gì?
Lời giải
(1) components.json (manifest), (2) token CSS theme trong stylesheet (:root/.dark), và (3) lib/utils.ts chứa helper cn().
3. Sau add dialog, dependency nào được cài tự động và vì sao?
Lời giải
@radix-ui/react-dialog — component Dialog của shadcn là wrapper đã style (Tailwind) bọc quanh primitive Radix đó để mượn hành vi + a11y (focus trap, ESC, ARIA).
4. Trong components.json, vì sao field aliases.utils lại đặc biệt dễ gây lỗi? Giá trị của nó nên trỏ tới đâu?
Lời giải
Vì mọi component add về đều import { cn } from "<aliases.utils>". Nếu alias (vd @/lib/utils) không khớp vị trí file cn thật (vd bạn để ở @/utils/cn), import sẽ vỡ. Nó phải trỏ đúng file chứa cn() — mặc định @/lib/utils.
5. Bạn đã sửa button.tsx thêm một variant brand. Giờ muốn lấy bản cập nhật mới từ registry mà không mất variant đó — làm thế nào? Vì sao không dùng --overwrite?
Lời giải
Chạy npx shadcn@latest diff button để xem phần upstream đổi gì, rồi merge tay các thay đổi đó vào file đã sửa. Không dùng add button --overwrite vì nó ghi đè toàn bộ file → mất variant brand. Cập nhật trong shadcn là thao tác merge, không phải npm update, chính vì bạn sở hữu code.
6. Ở vite.config.ts cần thêm gì để @/components/ui/button resolve được, dù tsconfig.json đã có paths?
Lời giải
import path from 'node:path';
export default defineConfig({
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
});TypeScript đọc paths để check kiểu, nhưng bundler Vite cần alias riêng để resolve lúc chạy/build. Phải khai ở cả hai nơi.
Nâng cao: mở file button.tsx trong project của bạn và ánh xạ từng dòng với phần series đã dạy nó (đối chiếu bảng ở mục 8).
Điểm chính
- shadcn là bản phân phối mã nguồn (“open code”), không phải thư viện cài đặt — CLI copy
.tsxthật vàocomponents/ui/, bạn sở hữu và sửa mọi dòng, không hộp đen, không!important, không kẹt version. - Mỗi component = Radix (hành vi + a11y, Phần 7/8) + Tailwind (vẻ ngoài, Phần 2/4) +
cva(variants, Phần 5) +cn(merge class, Phần 5) + token ngữ nghĩa (theme, Phần 3). shadcn chỉ ráp lại có gu. initghicomponents.json(manifest), chèn token CSS theme, và tạocn(). Đọc kỹ từng field củacomponents.json— đặc biệtaliases.utilsvàtailwind.css.add <comp>copy mã nguồn vàocomponents/ui/, tự cài dep Radix, thêmcva/clsx/tailwind-merge.add <URL>lấy từ registry bất kỳ (cơ chế Distribution).- Cập nhật là merge tay qua
diff, không phảinpm update.--overwritechỉ an toàn khi bạn chưa sửa file. - Tailwind v4:
tailwind.configrỗng, token trong CSS, màu OKLCH. React 19: bỏforwardRef. Hai lỗi alias (Vite +aliases.utils) là sự cố setup phổ biến nhất.
Tiếp theo
Phần 10 — Theme & tùy biến shadcn: cách theme biến-CSS hoạt động sâu, dựng light/dark mode, tạo theme brand riêng, tùy biến component đã copy, và dựng registry của bạn để phát hành & chia sẻ component qua URL.