Tailwind, Radix & shadcn/ui · Part 1 — The Mental Model & Setup (Tailwind v4)
Start from zero: why utility-first CSS exists, the one idea that makes Tailwind click, and a complete v4 setup with Vite — install, @import, @theme, and your first composed component. With a live playground.
Đây là Phần 1 của series Tailwind, Radix & shadcn/ui từ Zero đến Pro đưa bạn từ “cứ copy-paste class Tailwind mà không hiểu” đến thành thạo dựng hệ component dễ tiếp cận, dễ theme với Tailwind CSS v4 + Radix UI + shadcn/ui. Mỗi phần đều có config thật (setup → demo), một demo tương tác trực tiếp, gotcha production và bài tập để biến kiến thức thành phản xạ.
Ba công cụ này hợp thành một stack: Tailwind lo style, Radix lo hành vi headless dễ tiếp cận, và shadcn/ui là chất keo — component dựng sẵn mà bạn sở hữu, style bằng Tailwind, chạy trên Radix. Ta bắt đầu từ nền móng: Tailwind.
Sau bài này bạn phải làm được gì
Đây không phải bài “copy lệnh cài Tailwind rồi xong”. Khi đọc xong, bạn nên có đủ nền để:
- Nhìn một chuỗi class Tailwind và dịch ngược được nó đang sinh ra CSS gì.
- Hiểu vì sao utility-first không phải inline style trá hình, mà là cách đưa design token + state + responsive vào ngay nơi bạn dựng UI.
- Biết cách Tailwind quét source để sinh CSS, từ đó tránh lỗi ghép class động làm production mất style.
- Cài Tailwind v4 trong Vite/PostCSS/CLI và biết khác biệt quan trọng so với v3.
- Tự dựng một component nhỏ, rồi biết khi nào nên giữ class tại chỗ và khi nào nên tách thành component.
Mental model của senior
Người mới thường hỏi: “Tailwind có làm HTML bẩn không?”. Người đã đi qua vài dự án lớn sẽ hỏi câu khác: style này có còn đúng khi team có 20 người, app có 200 màn hình, và design system đổi ba lần không?
Tailwind trả lời bằng một nguyên tắc đơn giản: thay vì để mỗi dev tự phát minh selector và giá trị CSS, mọi người cùng dùng một ngôn ngữ nhỏ, bị ràng buộc bởi thang thiết kế chung. Class dài không phải vấn đề lớn nhất. Vấn đề lớn nhất là quyết định thiết kế bị rải rác, không ai biết đâu là nguồn đúng.
Trong series này, hãy đọc Tailwind theo ba tầng:
| Tầng | Bạn đang quyết định gì | Ví dụ |
|---|---|---|
| Utility | Một khai báo cụ thể tại đúng vị trí dùng nó | px-4, rounded-md, text-sm |
| Component | Một pattern UI lặp lại nhiều lần | Button, Card, DialogContent |
| Token | Một quyết định thiết kế toàn hệ thống | --color-primary, --radius-card |
Tư duy tốt là đi từ dưới lên: dùng utility để học và dựng nhanh, tách component khi pattern lặp lại, rồi nâng giá trị thành token khi nó là quyết định chung của sản phẩm. Nếu bạn token hóa mọi thứ từ ngày đầu, hệ thống nặng. Nếu bạn không token hóa gì cả, UI trôi dạt. Cái nghề nằm ở chỗ biết ranh giới.
1. Vấn đề mà utility-first giải quyết
CSS truyền thống bắt bạn nghĩ ra tên cho mọi thứ. Bạn viết .card, .card__title, .card--featured, rồi nhảy qua lại giữa file HTML và file CSS để giữ chúng đồng bộ. Nghe vô hại, nhưng nó sinh ra bốn vấn đề lớn dần theo dự án:
- Đặt tên là việc khó nhất.
.card,.product-card,.card-wrapper,.box… mỗi người đặt một kiểu. Sáu tháng sau không ai nhớ.ctakhác.btn-primarychỗ nào. - Context switching liên tục. Sửa một nút phải mở đúng file CSS, tìm đúng selector, sửa, quay lại HTML kiểm tra. Mỗi thay đổi nhỏ là một vòng nhảy file.
- CSS chỉ phình, không co. Stylesheet thực tế gần như chỉ thêm — vì xóa một class luôn rủi ro: “class này còn ai dùng không?” Không ai dám trả lời, nên không ai xóa. File phình mãi.
- Specificity war. Để override
.card .title, bạn viết.card .title.featured, rồi!important, rồi… Càng về sau càng khó đoán cái gì thắng cái gì.
/* Cách truyền thống — đặt tên + file riêng + nhảy qua lại + sợ xóa */
.btn-primary {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
background: #6366f1;
color: white;
font-weight: 600;
}
<button class="btn-primary">Save</button>
Tailwind lật ngược lại: thay vì đặt tên cho một component rồi định nghĩa style ở nơi khác, bạn áp các utility class nhỏ, đơn-nhiệm ngay trong markup.
<!-- Cách Tailwind — thiết kế ngay trong markup, không đặt tên, không file CSS -->
<button class="rounded-md bg-indigo-500 px-4 py-2 font-semibold text-white">
Save
</button>
Mỗi class là một khai báo CSS: px-4 → padding-left/right: 1rem, rounded-md → border-radius: 0.375rem, và cứ thế. Bốn vấn đề ở trên biến mất theo đúng thứ tự:
- Không cần đặt tên — bạn mô tả thẳng style.
- Không switch file — style ngay tại chỗ.
- Markup bị xóa thì class đi theo, không có CSS chết.
- Mọi utility cùng độ ưu tiên (specificity bằng nhau) → không còn chiến tranh specificity; thứ tự cuối cùng trong class thắng.
2. Utility class có phải inline style trá hình không?
Câu phản đối đầu tiên ai cũng nghĩ tới: “class="px-4 py-2" thì khác gì style="padding: …"?” Nhìn thì giống, nhưng inline style không làm được ba thứ cốt lõi — và đây mới là lý do Tailwind tồn tại:
1. Design tokens (giá trị bị ràng buộc). Inline style cho bạn gõ giá trị tùy tiện: padding: 13px, chỗ khác 15px, chỗ khác 17px — UI lệch nhịp mà không ai nhận ra. Utility chỉ rút từ một thang cố định (p-2, p-4, p-6…), nên mọi khoảng cách trong app tự khớp nhau.
<!-- inline: tùy tiện, dễ lệch -->
<div style="padding: 13px; margin-top: 19px"></div>
<!-- utility: rút từ thang chung, luôn nhất quán -->
<div class="mt-5 p-3"></div>
2. States (hover, focus, disabled…). CSS inline không thể biểu diễn :hover, :focus, :disabled. Bạn buộc phải tách ra stylesheet hoặc viết JS. Tailwind làm được ngay trong markup bằng variant prefix (học kỹ ở Phần 4):
<button
class="bg-indigo-500 hover:bg-indigo-600 focus:ring-2 disabled:opacity-50"
>
Save
</button>
3. Responsive & dark mode. Inline style không gắn được @media. Tailwind có prefix md:, lg:, dark: — viết thẳng vào class:
<div class="grid grid-cols-1 bg-white md:grid-cols-3 dark:bg-slate-900"></div>
Chốt: utility class = inline style cộng thêm design tokens, states, responsive và dark mode. Đó là khác biệt về chất, không phải cách viết khác.
3. Một ý tưởng làm mọi thứ “thông”
Một utility class là cái tên cho một khai báo CSS đơn lẻ, cố định.
Các giá trị không tùy tiện — chúng đến từ một thang design system dựng sẵn. Hiểu thang này là hiểu 80% Tailwind, vì hầu hết utility chỉ là “tên thang + bước”:
| Thang | Ví dụ class | Giá trị sinh ra | Quy luật |
|---|---|---|---|
| Spacing | p-4, m-2, gap-6 | 1rem, 0.5rem, 1.5rem | bước × 0.25rem |
| Font size | text-sm, text-lg, text-2xl | 0.875rem, 1.125rem, 1.5rem | thang riêng (xem Phần 2) |
| Radius | rounded, rounded-md, rounded-xl | 0.25rem, 0.375rem, 0.75rem | thang riêng |
| Shadow | shadow-sm, shadow, shadow-lg | bộ box-shadow dựng sẵn | thang riêng |
| Màu | bg-indigo-500, text-slate-600 | hex trong bảng màu | tên-sắc độ (50…950) |
Hai trục đáng thuộc nằm lòng vì gặp mỗi ngày:
- Spacing: số
n→n × 0.25rem. Nênp-4 = 1rem = 16px,p-2 = 8px,p-8 = 32px. Cứ nhân 4 ra pixel. - Sắc độ màu: mỗi màu có 11 nấc
50(nhạt nhất) →950(đậm nhất).500là “chuẩn”. Chữ trên nền sáng thường700–900, nền nhấn thường500–600.
Nhờ thang chung này, UI của bạn nhất quán theo thiết kế — bạn chỉ chọn được trong thang, nên không thể vô tình lệch 1px.
Thử ngay bên dưới — bật/tắt utility và xem chuỗi class cùng CSS tương đương mà Tailwind sinh ra:
Còn phản đối “markup xấu / dài quá”? Sau một tuần bạn ngừng “đọc” từng class, y như đã ngừng đọc display: flex từ lâu. Còn việc lặp lại một cụm class nhiều nơi được giải quyết bằng component (Phần 5), không phải bằng cách quay lại đặt tên CSS.
4. Tailwind sinh CSS như thế nào (và một cái bẫy)
Đây là phần hầu hết người mới bỏ qua, rồi dính bug “tự nhiên class không ăn”. Tailwind không ship sẵn hàng triệu class rồi để trình duyệt lọc. Thay vào đó nó chạy một bước build:
Quét toàn bộ source (.html, .tsx, .jsx, .vue…)
↓
Trích ra mọi chuỗi trông giống tên class
↓
Chỉ generate CSS cho các class thực sự xuất hiện
↓
Output: 1 file CSS gọn, chỉ chứa cái đang dùng (production thường vài kB)
Vì cơ chế quét text tĩnh này, có một quy tắc bất di bất dịch: không bao giờ ghép tên class bằng chuỗi động. Scanner chỉ thấy text như nó nằm trong file — nó không chạy code của bạn.
// ❌ Scanner thấy "text-${color}-600" → không khớp pattern nào → class không tồn tại
const cls = `text-${color}-600`;
// ✅ Scanner thấy nguyên chuỗi "text-red-600" và "text-blue-600" → generate cả hai
const cls = { red: 'text-red-600', blue: 'text-blue-600' }[color];
Debug “class không ăn”: 99% là (1) ghép chuỗi động như trên, (2) file chứa class nằm ngoài phạm vi Tailwind quét, hoặc (3) gõ sai tên (dùng IntelliSense ở mục 7 để khỏi sai). Mở DevTools, inspect element — nếu không thấy rule CSS nào cho class đó, nghĩa là Tailwind chưa generate nó, chứ không phải bị override.
5. Cài đặt — Tailwind v4 với Vite
Ta nhắm Tailwind v4, đơn giản hơn hẳn v3. Setup phổ biến nhất cho React là Vite.
Bước 1 — cài:
npm install tailwindcss @tailwindcss/vite
Bước 2 — thêm plugin Vite. Ở v4 không cần config PostCSS và không bắt buộc tailwind.config.js — plugin lo hết:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});
Bước 3 — import Tailwind trong CSS. Một dòng này thay cho bộ ba @tailwind base/components/utilities cũ:
/* src/index.css */
@import 'tailwindcss';
Bước 4 — đảm bảo CSS được nạp một lần, tại entry của app:
// src/main.tsx
import './index.css';
Xong — npm run dev và mọi utility class đã sẵn sàng.
Không dùng Vite thì sao?
Tailwind v4 chạy được ở mọi nơi, chỉ khác cách “cắm” vào pipeline:
| Môi trường | Cách cài | Ghi chú |
|---|---|---|
| Vite | @tailwindcss/vite | Nhanh nhất, khuyến nghị cho React/Vue/Svelte |
| PostCSS (Next.js, CRA…) | @tailwindcss/postcss trong postcss.config.mjs | Dùng khi framework đã có pipeline PostCSS |
| CLI (không bundler) | npx @tailwindcss/cli -i in.css -o out.css --watch | Cho HTML thuần, prototype nhanh |
// postcss.config.mjs — bản PostCSS, ví dụ cho Next.js
export default {
plugins: { '@tailwindcss/postcss': {} },
};
Bẫy nâng cấp: ở v4, plugin PostCSS tách thành package riêng
@tailwindcss/postcss. Nếu copy config v3 (tailwindcss: {}trong PostCSS) sang v4, build sẽ lỗi. Đây là lỗi “nâng cấp v3→v4” gặp nhiều nhất.
6. v3 vs v4 — khác ở đâu
Bạn sẽ còn gặp codebase v3 dài dài, nên nắm bảng đối chiếu này để khỏi loạn. Tin tốt: các utility class gần như giống hệt — chỉ khác phần setup và cấu hình.
| Tailwind v3 | Tailwind v4 | |
|---|---|---|
| Khai báo trong CSS | @tailwind base; @tailwind components; @tailwind utilities; | @import "tailwindcss"; (một dòng) |
| Cấu hình | tailwind.config.js (JS) | @theme { … } ngay trong CSS (Phần 3) |
| Build | Cần PostCSS + autoprefixer | Plugin Vite/PostCSS lo hết, không cần autoprefixer |
| Engine | JIT bật bằng tay (v3.0) / mặc định (v3.1+) | JIT là mặc định, viết bằng Rust → nhanh hơn nhiều |
| Token → biến CSS | Không tự động | Mọi token thành var(--…) dùng được ở JS/DevTools |
Nếu phải nâng cấp một dự án v3, Tailwind có công cụ tự động: npx @tailwindcss/upgrade. Nó đổi @tailwind directive, di chuyển config sang @theme, và sửa các tên class đã đổi.
7. Công cụ editor — cài một lần, lợi mãi
Hai thứ này biến “học thuộc class” thành “khám phá khi đang gõ”:
1. Tailwind CSS IntelliSense (extension VS Code). Cho bạn:
- Autocomplete mọi class kèm preview giá trị,
- Hover lên class để xem đúng CSS nó sinh ra,
- Ô màu hiện ngay cạnh class màu,
- Cảnh báo khi bạn dùng hai class xung đột (ví dụ
p-2 p-4).
2. prettier-plugin-tailwindcss — tự sắp xếp class theo đúng thứ tự khuyến nghị của Tailwind, nên mọi file trong team trông nhất quán và diff sạch:
npm install -D prettier prettier-plugin-tailwindcss
// prettier.config.js
export default { plugins: ['prettier-plugin-tailwindcss'] };
Sau khi cài, class="text-white p-4 flex bg-indigo-500" sẽ tự sắp lại thành thứ tự chuẩn mỗi lần lưu — bạn không phải nghĩ về thứ tự nữa.
8. Component đầu tiên do bạn ghép
Đọc một card thật từng dòng — mỗi class là một khai báo bạn đã nhận ra:
<div class="max-w-sm rounded-xl bg-white p-6 shadow-md">
<h3 class="text-lg font-semibold text-slate-900">Deploy faster</h3>
<p class="mt-2 text-sm text-slate-600">
Ship your UI without writing a single line of custom CSS.
</p>
<button
class="mt-4 rounded-md bg-indigo-500 px-4 py-2 text-sm font-medium text-white"
>
Get started
</button>
</div>
Dịch từng class ngược về CSS — không có gì bí ẩn:
| Class | CSS tương đương | Đọc thành |
|---|---|---|
max-w-sm | max-width: 24rem | hộp không rộng quá, dễ đọc |
rounded-xl | border-radius: 0.75rem | bo góc lớn |
bg-white | background: #fff | nền trắng |
p-6 | padding: 1.5rem | đệm trong đều 4 phía |
shadow-md | box-shadow: … | đổ bóng vừa |
text-lg font-semibold | font-size: 1.125rem; font-weight: 600 | tiêu đề nổi |
text-slate-900 | color: #0f172a | gần đen |
mt-2 / mt-4 | margin-top: 0.5rem / 1rem | giãn dọc theo cùng thang |
text-sm | font-size: 0.875rem | chữ phụ nhỏ hơn |
Để ý: mt-2 và mt-4 cùng rút từ thang spacing, nên nhịp dọc tự cân mà bạn không phải đo 0.5rem hay 1rem gì cả — chỉ chọn “bước 2” hay “bước 4”. Sự nhất quán đó — mọi giá trị rút từ một thang chung — mới là phần thưởng thật của utility-first, và là nền cho mọi thứ còn lại trong series.
9. Khi nào giữ class, khi nào tách component
Một hiểu lầm phổ biến: dùng Tailwind nghĩa là mọi thứ phải nằm mãi trong class="". Không đúng. Tailwind giải quyết cách viết style, không thay thế tư duy component. Quy tắc thực dụng:
| Tình huống | Nên làm gì | Vì sao |
|---|---|---|
| UI xuất hiện một lần | Giữ class ngay trong markup | Dễ đọc tại chỗ, không sinh abstraction giả |
| Cùng cụm class lặp lại 2-3 lần | Tách component nhỏ | Đổi style một nơi, tránh copy-paste lệch |
Component có nhiều variant (size, intent, loading) | Dùng cn()/cva ở Phần 5 | Quản lý điều kiện class rõ ràng, tránh chuỗi nối tay |
| Giá trị thiết kế lặp lại khắp app | Nâng thành token @theme ở Phần 3 | Đổi design system một nơi, mọi UI đi theo |
| Một selector phức tạp thật sự cần CSS | Viết CSS thủ công, vẫn dùng token | Tailwind không cấm CSS; nó chỉ giúp CSS hiếm và có chủ đích hơn |
Ví dụ: một nút xuất hiện một lần trong prototype thì viết thẳng:
<button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white">
Save
</button>
Nhưng nếu nút đó xuất hiện khắp app, hãy tách thành component. Khi đó Tailwind vẫn là ngôn ngữ style bên trong component, còn React/Vue/Svelte lo tái sử dụng:
function Button({ children }: { children: React.ReactNode }) {
return (
<button className="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
{children}
</button>
);
}
Tiêu chuẩn senior: abstraction chỉ đáng tạo khi nó giảm số nơi phải sửa, làm API dễ dùng hơn, hoặc khóa được một quyết định thiết kế quan trọng. Tách component chỉ vì “class dài nhìn khó chịu” thường tạo ra code khó bảo trì hơn.
10. Gotchas thường gặp ngay từ ngày đầu
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Class “không ăn” gì cả | Ghép tên class bằng template string động | Map sang chuỗi đầy đủ (mục 4) |
| Class ăn ở dev, mất khi build | File chứa class nằm ngoài phạm vi Tailwind quét | Đảm bảo file nằm trong source; v4 tự dò, nhưng file lạ vị trí thì khai báo @source |
Hai class “đánh nhau” như p-2 p-4 | Cả hai cùng thuộc tính, class sau trong CSS thắng — không phải class viết sau trong markup | Bỏ bớt một cái; dùng tailwind-merge khi build component (Phần 5) |
| Muốn một giá trị ngoài thang | Thang không có sẵn số bạn cần | Dùng arbitrary value: top-[117px], bg-[#1da1f2] — nhưng tiết chế, vì mất tính nhất quán |
::before/::after không hiện | Thiếu content | Luôn kèm before:content-[''] |
Hai gotcha “class không ăn” và “p-2 p-4 đánh nhau” chiếm phần lớn câu hỏi của người mới. Nhớ: nếu DevTools không thấy rule cho class → Tailwind chưa generate (lỗi quét/ghép chuỗi); nếu thấy nhưng bị gạch ngang → bị class khác override (vấn đề thứ tự).
11. Bài tập
Làm trong một project Vite + React mới với setup ở trên.
1. Dựng lại nút “Save” từ đầu, rồi chỉ đổi màu sang emerald và bo tròn hoàn toàn.
Lời giải
<button class="rounded-full bg-emerald-500 px-4 py-2 font-semibold text-white">
Save
</button>Chỉ đổi bg-indigo-500 → bg-emerald-500 và rounded-md → rounded-full.
2. Không tra cứu, viết CSS mà p-8 sinh ra.
Lời giải
thang spacing là bước × 0.25rem, nên 8 × 0.25 = 2rem (32px).
3. Dựng một “tag” pill: chữ nhỏ, padding ngang 0.5rem, padding dọc 0.125rem, bo tròn, nền xám nhạt, chữ xám đậm.
Lời giải
<span class="rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-700"
>New</span
>4. Designer đưa bạn padding: 24px. Class nào ra đúng giá trị đó không cần arbitrary value?
Lời giải
24 ÷ 4 = 6, nên p-6. Mẹo chia-4 này áp cho mọi utility spacing.
5. Đoạn <div class="p-2 p-4"> render ra padding bao nhiêu, và tại sao?
Lời giải
1rem — vì cả hai là cùng thuộc tính padding, class đứng sau trong CSS sinh ra (p-4) thắng, không liên quan tới thứ tự trong chuỗi class. Đây là lý do ta cần tailwind-merge khi dựng component (Phần 5).
Nâng cao: mở playground ở mục 3 và tái tạo đúng CSS của card bằng cách bật từng chip.
6. Một cụm class button xuất hiện ở 8 nơi, trong đó có 3 nơi sửa nhầm px-3 thay vì px-4. Bạn nên sửa bằng cách nào?
Lời giải
Tách thành component Button hoặc một helper class composition ở tầng framework. Đây là lỗi do copy-paste pattern lặp lại, không phải lỗi của Tailwind. Sau khi tách, mọi nơi gọi cùng một API; style chỉ còn một nguồn.
Điểm chính
- Utility-first = thiết kế ngay trong markup bằng các class nhỏ đơn-nhiệm, thay vì đặt tên component và sửa stylesheet riêng — xoá luôn 4 nỗi đau: đặt tên, switch file, CSS chết, specificity war.
- Utility không phải inline style: nó có thêm design tokens, states (
hover:), responsive (md:) và dark mode — thứ inline style không làm được. - Một utility = một khai báo CSS, giá trị rút từ thang design chung (spacing
× 0.25rem, màu50→950) → nhất quán theo thiết kế. - Tailwind quét text tĩnh rồi chỉ sinh CSS cho class thực dùng → output vài kB. Hệ quả: không bao giờ ghép tên class bằng chuỗi động.
- Setup v4 cực gọn: plugin
@tailwindcss/vite+ một@import "tailwindcss". Không PostCSS riêng, không bắt buộc config JS. Cấu hình chuyển vào@theme(Phần 3). - Cài Tailwind IntelliSense + prettier-plugin-tailwindcss — biến học thuộc thành khám phá, và giữ class luôn đúng thứ tự.
Tiếp theo
Phần 2 — Utility cốt lõi & layout: những utility bạn dùng hằng ngày — spacing, sizing, typography, màu, và hai cỗ máy layout (Flexbox & Grid) theo kiểu Tailwind — cùng hệ responsive giúp một component thích ứng mọi màn hình.