jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ệcCách nghĩ đúng
Thêm componentCLI tạo bản baseline tốt, không phải chân lý cuối cùng
Sửa styleSửa trực tiếp file trong components/ui, commit như code của bạn
Thêm variantSửa cva trong component, không override từ ngoài
Nâng cấp componentXem diff registry mới, merge có chủ đích
Chuẩn hóa teamViế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ộtNghĩa làVì sao quan trọng
Open CodeCode component nằm trong repo bạn, sửa trực tiếpAI/đồng đội đọc & sửa được; không bí ẩn
CompositionMọ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ộ
DistributionMột registry (schema + CLI) để phát hành & chia sẻ component qua URLBạn cũng tự phát hành component của mình được (Phần 10)
Beautiful defaultsStyle 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ữuBạn sở hữu & sửa được từng dòngChỉ restyle từ ngoài
Runtime depKhông có dep “thư viện UI”; chỉ Radix + vài util nhỏMột import lớn, ít code trong repo
Hộp đenKhông — mọi thứ lộ thiên trong components/ui/Có — logic ẩn trong node_modules
Cập nhậtBạn áp thủ công (diff & merge)npm update tự động
Đường cong họcDùng Tailwind + Radix bạn đã biếtPhải học hệ styling riêng của thư viện
BundleChỉ ship component bạn thực dùngTree-shaking tùy thư viện
Khi cần sửa sâuMở file, sửa, xongCó 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 shadcnHọc ở
Radix UI@radix-ui/react-*Cung cấp hành vi headless: focus trap, keyboard nav, ARIA, asChild/SlotPhần 7 (primitives), Phần 8 (composition, asChild, state)
Tailwindutility classToà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)
cvaclass-variance-authorityKhai báo bảng variant/size + defaultVariants thành type-safe propsPhần 5
cnclsx + tailwind-mergeGộp class nội bộ với className người dùng truyền vào, giải xung đột class trùngPhầ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ầuVì saoKiểm tra nhanh
React (Vite, Next.js, Astro, Remix, Laravel…)Component là .tsx/.jsx Reactreact trong package.json
Tailwind đã cấu hìnhMọ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ợ .jsxtsconfig.json
Path alias @/*Component import nhau qua @/components/...@/lib/utilsthấ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ém Failed 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ải shadcn-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:

PromptLựa chọnNó quyết định gì
Which style?new-york hoặc defaultBộ style mặc định (new-york gọn, sắc hơn). Ghi vào style
Base color?zinc · slate · stone · gray · neutralTô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 --primary này thành utility bg-primary, và màu mặc định là OKLCH thay vì HSL (như v3). Nếu bạn thấy oklch(...) 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"
}
FieldKiểuÝ nghĩaGhi chú
$schemaURLSchema để 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
rscbooleanReact Server Components? Nếu true, CLI thêm "use client" vào component cầntrue cho Next.js App Router; false cho Vite/SPA
tsxbooleanSinh .tsx (true) hay .jsx (false)false nếu project không dùng TS
tailwind.configpathĐường dẫn tailwind.config (v3)Để rỗng "" ở Tailwind v4 — v4 không cần file config
tailwind.csspathFile CSS chứa @import "tailwindcss" & token themeCLI chèn biến theme vào đây
tailwind.baseColorstringTông xám nền (zinc/slate/…)Chỉ dùng lúc init để sinh token; đổi sau không hồi tố
tailwind.cssVariablesbooleantrue → token bg-primary; false → class màu trực tiếptrue để theme-able (khuyến nghị)
tailwind.prefixstringPrefix class Tailwind (vd tw-)Để rỗng nếu không dùng prefix
aliases.componentsaliasThư mục gốc cho componentMap tới @/components
aliases.uialiasNơi đặt component shadcn@/components/ui
aliases.utilsaliasĐường dẫn tới cn()@/lib/utilsphải khớp nơi file utils thật nằm
aliases.libaliasThư mục lib chung@/lib
aliases.hooksaliasNơi đặt custom hooks (vd use-toast)@/hooks
iconLibrary"lucide" | "radix"Bộ icon component dùnglucide mặc định; component import từ đây

aliases.utils là field hay gây lỗi nhất. Nếu bạn để cn()src/utils/cn.ts nhưng components.json trỏ @/lib/utils, thì mọi component add về 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ẫnChứaAi tạo
components/ui/*.tsxComponent shadcn — file của bạn, sửa thoải máiadd
lib/utils.tscn() và helper chunginit
index.css (hoặc globals.css)@import "tailwindcss" + token theme :root/.darkinit
hooks/*.tsHook đ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ệnhTác dụngKhi nào dùng
diff <component>In ra phần khác giữa local & upstreamTrước khi quyết định cập nhật
add <component> --overwriteGhi đè file local bằng bản mớiKhi bạn chưa sửa file đó
(chép thủ công từ diff)Merge tay phần upstream vào file đã sửaKhi 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ửa button.tsx, đừng --overwrite mù — chạy diff trướ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ệmLà 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/sizePhần 5
cn(buttonVariants(...), className)Gộp class nội bộ + className ngoài, giải xung đột nhờ tailwind-mergePhần 5
bg-primary, text-primary-foregroundToken ngữ nghĩa từ biến CSS themePhần 3
hover:bg-primary/90, focus-visible:ring-2, disabled:opacity-50Variant statePhần 4
inline-flex … rounded-md text-smUtility layout/typography cơ bảnPhần 2
VariantProps<typeof buttonVariants>Suy ra kiểu prop từ bảng cvaPhầ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 FormInput 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 form kéo theo react-hook-form, zod, @hookform/resolvers và component Form* (bọc quanh react-hook-form + Radix Label). add dialog cà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ớiHệ quả
Tailwind v4tailwind.config để rỗng "" trong components.json; token định nghĩa trong CSS qua @theme/:rootKhông có file tailwind.config.js để chỉnh — sửa token ngay trong CSS (Phần 3)
Màu OKLCHToken theme dùng oklch(...) thay HSLMàu đồng đều về thị giác hơn; copy theme cũ HSL cần chuyển đổi
React 19forwardRef phần lớn không còn cần — ref là prop thườngComponent 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 đơnadd tự chọn import đúng — đừng tự thêm tay
data-slotComponent mới gắn data-slot="..." cho mỗi phầnTiệ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/upgrade cho Tailwind trước (đổi @tailwind@import, di chuyển config sang @theme), rồi npx shadcn@latest diff từng component để áp các thay đổi cấu trúc (vd bỏ forwardRef, thêm data-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ứngNguyên nhânCách xử lý
Failed to resolve import "@/lib/utils"Alias @ chỉ khai trong tsconfig, chưa khai ở vite.config.tsThê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ậtSửa alias hoặc di chuyển file cho khớp
Mất hết tùy biến sau khi cập nhậtChạy add <comp> --overwrite đè lên file bạn đã sửaDù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ạiPackage cũ shadcn-ui đã ngừngDùng shadcn (bỏ -ui)
Token bg-primary không ra màucssVariables: 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.jsonHai 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 validateChưa cài zod + @hookform/resolversadd 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.utils lệ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.json vite.config.ts, rồi đối chiếu aliases.utils với đường dẫn file cn thậ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 .tsx thật vào components/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.
  • init ghi components.json (manifest), chèn token CSS theme, và tạo cn(). Đọc kỹ từng field của components.json — đặc biệt aliases.utilstailwind.css.
  • add <comp> copy mã nguồn vào components/ui/, tự cài dep Radix, thêm cva/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ải npm update. --overwrite chỉ an toàn khi bạn chưa sửa file.
  • Tailwind v4: tailwind.config rỗ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.