jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Tailwind, Radix & shadcn/ui · Part 6 — Plugins & the Ecosystem

Round out a pro setup: the typography plugin for prose, forms for sane inputs, tw-animate for enter/exit motion, and writing your own utilities and variants in v4 with @utility and @custom-variant. With a live plugin showcase.

Bạn đã dựng được gần như mọi thứ chỉ với utility lõi. Nhưng có những nhu cầu lặp đi lặp lại — style một bài Markdown dài, làm <select> trông tử tế, cho dialog hiệu ứng bật ra/đóng vào — mà nếu tự viết từ utility thì vừa dài vừa dễ sai. Plugin đóng gói sẵn những thứ đó thành các gói utility chuyên dụng. Và v4 còn cho bạn tự viết utility lẫn variant ngay trong CSS, biến mọi pattern riêng của app thành class có thể tái dùng.

Phần này đi sâu vào ba plugin chính thức bạn sẽ cài ở hầu hết project (typography, forms, tailwindcss-animate), các primitive mở rộng của v4 (@utility, @custom-variant), và cuối cùng là bản đồ hệ sinh thái component cộng đồng — Headless UI, Radix, daisyUI, Flowbite — để bạn biết khi nào chọn cái nào. Đọc xong, bạn không cần mở docs của bất kỳ plugin nào ở trên nữa.

Nguyên tắc chọn plugin

Plugin tốt làm codebase gọn hơn. Plugin thừa làm surface area rộng hơn, build khó đoán hơn, và đôi khi style đánh nhau. Trước khi cài, dùng checklist này:

Câu hỏiNếu câu trả lời là “có”
Tính năng đã có trong lõi Tailwind v4 chưa?Không cài plugin cũ (aspect-ratio, container query v3…)
Pattern này lặp ở nhiều nơi và khó viết đúng tay không?Plugin/utility riêng có giá trị
Plugin có sinh base style toàn cục không?Kiểm tra nguy cơ đụng UI kit/shadcn/legacy CSS
Nó có giải quyết hành vi hay chỉ style?Hành vi phức tạp nên dùng primitive như Radix, không chỉ plugin CSS
Team có hiểu class plugin sinh ra không?Viết guide ngắn hoặc wrapper component

Một nguyên tắc mình dùng trong dự án thật: cài plugin cho pattern khó, không cài plugin cho sự lười biếng nhất thời. Nếu chỉ để rút ngắn ba utility, thường @utility riêng hoặc component là đủ.


1. Plugin là gì, và v4 nạp nó như thế nào

Một plugin Tailwind là một đoạn code chạy ở thời điểm build, “tiêm” thêm vào engine: utility mới (prose, animate-in), variant mới, base style, hoặc cả token vào theme. Nó không phải file CSS tĩnh — nó là code sinh ra CSS, nên một plugin như typography có thể tạo hàng trăm rule chỉ từ một dòng khai báo.

Điểm khác biệt lớn nhất giữa v3 và v4 là nơi bạn khai báo plugin. v3 nạp qua mảng plugins trong file JS; v4 nạp thẳng từ CSS bằng directive @plugin:

/* v4 — nạp plugin ngay trong file CSS, cùng chỗ với @import */
@import "tailwindcss";

@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
// v3 — phải có tailwind.config.js và một mảng plugins
module.exports = {
  plugins: [
    require("@tailwindcss/typography"),
    require("@tailwindcss/forms"),
  ],
};

Cơ chế bên dưới: khi engine gặp @plugin "tên", nó import package đó, gọi hàm plugin và hợp nhất output (utility, variant, base) vào pipeline trước khi quét source. Vì vậy thứ tự khai báo có ý nghĩa — plugin nạp sau có thể ghi đè token/utility của plugin nạp trước.

  @import "tailwindcss"

  @plugin "A"  → import A() → thêm utility/variant/base của A

  @plugin "B"  → import B() → thêm của B (ghi đè A nếu trùng)

  Quét source, generate CSS cho class thực dùng (kể cả class do plugin tạo)

  Output CSS cuối
Tailwind v3Tailwind v4
Khai báo pluginmảng plugins: [...] trong JS@plugin "..." trong CSS
Bắt buộc file config?Có (tailwind.config.js)Không
Nạp một file CSS thuần (vd animate)thường vẫn qua plugin JS@import "..." ngay trong CSS
Truyền option cho plugingọi require("x")({...})khối @plugin "x" { ... }

Một số plugin (như tailwindcss-animate/tw-animate-css) thực ra chỉ là CSS thuần — chúng được nạp bằng @import "..." chứ không cần @plugin. Phân biệt: nếu package sinh utility bằng code@plugin; nếu nó chỉ là một file CSS có sẵn @utility/@theme@import.

So sánh trước/sau của từng plugin trong showcase tương tác bên dưới — bật/tắt plugin và xem cùng một HTML đổi hình:

Bẫy nâng cấp v3→v4: nếu bạn copy tailwind.config.js cũ với mảng plugins sang v4, engine sẽ không tự đọc nó trừ khi bạn @config "./tailwind.config.js". Cách “thuần v4” là chuyển từng plugin sang @plugin trong CSS và bỏ file config.


2. @tailwindcss/typography — class prose

Khi bạn render nội dung mà không kiểm soát từng phần tử — Markdown, MDX, output từ CMS, HTML từ editor — bạn không thể gắn utility lên từng <h2>, <p>, <ul>. Class prose giải đúng bài này: nó style cả cây con bằng một bộ mặc định typography đẹp và cân đối (khoảng cách dòng, margin giữa các khối, style cho code, blockquote, table, img…).

npm install -D @tailwindcss/typography
@import "tailwindcss";
@plugin "@tailwindcss/typography";
<article class="prose">
  <!-- HTML thô từ Markdown: h1..h6, p, ul, ol, blockquote, code, pre, img, table… -->
</article>

Cơ chế: prose style như thế nào

Khác mọi utility lõi (chỉ tác động lên đúng phần tử mang class), prose đặt một loạt rule kiểu .prose :where(p):not(:where(.not-prose *)) { ... }. Hai điều quan trọng rút ra:

  1. Nó dùng :where() để giữ specificity = 0, nên bạn vẫn ghi đè được bằng một utility bình thường (prose không “thắng” class của bạn).
  2. Nó tự loại trừ mọi thứ nằm trong .not-prose — bạn gắn not-prose lên một khối con để “thoát” khỏi style prose (ví dụ nhúng một component có style riêng giữa bài viết).

Bảng modifier kích cỡ

Chọn một, và đặt responsive như mọi prefix khác:

ClassCỡ chữ nềnDùng cho
prose-sm0.875remchú thích, sidebar, UI gọn
prose-base (mặc định)1remthân bài tiêu chuẩn
prose-lg1.125rembài đọc dài, blog
prose-xl1.25remlanding, bài nhấn
prose-2xl1.5remhero, trích dẫn lớn
<!-- nhỏ trên mobile, lớn dần lên desktop -->
<article class="prose prose-sm md:prose-base lg:prose-lg">…</article>

Bảng modifier màu & theme

prose có sẵn các “color theme” theo thang xám của Tailwind, cộng prose-invert để đảo cho dark mode:

ClassTác dụng
prose-gray (mặc định)thang xám trung tính
prose-slate / prose-zinc / prose-neutral / prose-stoneđổi tông xám của chữ & viền
prose-invertđảo toàn bộ sang bảng màu sáng-trên-tối → dùng cho dark mode
<!-- mặc định sáng; tự đảo khi trang ở dark mode -->
<article class="prose dark:prose-invert">…</article>

Bảng element modifier — chỉnh từng loại phần tử

Đây là phần mạnh nhất: tiền tố prose-<phần tử>: cho bạn áp bất kỳ utility nào lên một loại phần tử bên trong vùng prose, mà không cần CSS riêng.

ModifierNhắm vàoVí dụ
prose-headings:mọi h1h6prose-headings:font-display prose-headings:tracking-tight
prose-h2:riêng h2prose-h2:mt-12 prose-h2:border-b
prose-p:đoạn vănprose-p:leading-relaxed
prose-a:liên kếtprose-a:text-indigo-500 prose-a:no-underline hover:prose-a:underline
prose-strong:chữ đậmprose-strong:text-slate-900
prose-code:inline codeprose-code:rounded prose-code:bg-slate-100 prose-code:px-1
prose-pre:khối codeprose-pre:bg-slate-900 prose-pre:rounded-xl
prose-blockquote:trích dẫnprose-blockquote:border-indigo-500
prose-img:ảnhprose-img:rounded-xl prose-img:shadow-lg
prose-ul: / prose-ol: / prose-li:danh sáchprose-li:my-1
prose-th: / prose-td:bảngprose-th:bg-slate-100
prose-hr:đường kẻprose-hr:border-slate-200
<article
  class="prose prose-slate max-w-none
         prose-headings:font-semibold prose-headings:tracking-tight
         prose-a:text-indigo-600 prose-a:no-underline hover:prose-a:underline
         prose-code:before:content-[''] prose-code:after:content-['']
         prose-img:rounded-xl prose-pre:rounded-xl"
>

</article>

Mẹo hay quên: mặc định prose giới hạn bề rộng ~65ký tự (max-width: 65ch) cho dễ đọc. Trong layout đã có cột hẹp sẵn, thêm max-w-none để bỏ giới hạn này, nếu không bài viết bị bó cứng giữa trang.

Tùy biến sâu qua @theme / CSS variable

Mọi giá trị của prose đều là CSS custom property dạng --tw-prose-*, nên bạn chỉnh tông màu toàn cục mà không cần đụng từng modifier:

@import "tailwindcss";
@plugin "@tailwindcss/typography";

/* đổi màu link & chữ đậm cho mọi vùng prose, cả light lẫn dark */
.prose {
  --tw-prose-links: var(--color-indigo-600);
  --tw-prose-bold: var(--color-slate-900);
}
.dark .prose,
.prose-invert {
  --tw-prose-links: var(--color-indigo-400);
}

3. @tailwindcss/forms — control form tử tế

Control form gốc (<input>, <select>, <textarea>, checkbox, radio) render khác nhau giữa các trình duyệt và phần lớn bỏ qua các utility như rounded-*, border-*, focus:ring-* vì chúng dùng style appearance gốc của hệ điều hành. Plugin forms reset chúng về một nền trung tính, thân thiện với utility để mọi class bạn gắn đều ăn như kỳ vọng.

npm install -D @tailwindcss/forms
@import "tailwindcss";
@plugin "@tailwindcss/forms";
<input
  type="email"
  class="w-full rounded-lg border-slate-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
/>

<select class="rounded-lg border-slate-300 focus:border-indigo-500 focus:ring-indigo-500">
  <option>Một</option>
  <option>Hai</option>
</select>

<input type="checkbox" class="rounded text-indigo-600 focus:ring-indigo-500" />

Hai chiến lược: base vs class

Plugin có một option quan trọng — nó áp reset cho tất cả input (base, mặc định) hay chỉ cho các phần tử bạn chủ động opt-in qua class form-input, form-select… (class):

/* chỉ reset khi bạn gắn class form-* — tránh đụng tới input của thư viện khác */
@plugin "@tailwindcss/forms" {
  strategy: class;
}
<!-- ở chiến lược class, phải tự thêm form-input/form-select/form-checkbox… -->
<input type="text" class="form-input rounded-lg border-slate-300" />
<select class="form-select rounded-lg border-slate-300">…</select>
Chiến lượcKhi nào dùng
base (mặc định)App của riêng bạn, muốn mọi input “đẹp sẵn” không cần nghĩ
classApp tích hợp UI kit/legacy khác, sợ reset toàn cục đụng style của họ

Lưu ý shadcn: shadcn/ui không dùng @tailwindcss/forms; nó tự style input bằng utility trong từng component. Đừng cài cả hai một cách vô tình rồi ngạc nhiên vì style “đánh nhau” — chọn một hướng cho dự án.


4. Utility animation — tailwindcss-animate / tw-animate-css

Đây là plugin làm nên các hiệu ứng “bật ra / đóng vào” của shadcn/ui: menu trượt xuống, dialog phóng to mờ dần, tooltip nảy nhẹ. Nó cấp một bộ utility enter/exit kết hợp được với data-[state] mà Radix phát ra (data-state="open" | "closed").

Ở v3, plugin tên tailwindcss-animate (nạp qua @plugin). Ở v4, bản kế thừa thuần-CSS là tw-animate-css, nạp bằng @import:

# v4
npm install -D tw-animate-css
@import "tailwindcss";
@import "tw-animate-css";
# v3 (để đối chiếu, nếu bạn còn dự án cũ)
npm install -D tailwindcss-animate

Cách hoạt động: 2 utility “công tắc” + nhiều utility “thành phần”

Cơ chế chia làm hai lớp. Trước hết là hai utility bật animation, đặt animation-name:

UtilityÝ nghĩa
animate-inchạy keyframe enter (phần tử xuất hiện)
animate-outchạy keyframe exit (phần tử biến mất)

Rồi bạn cộng thêm các utility thành phần mô tả enter/exit cụ thể — chúng cộng dồn:

NhómUtility (enter / exit)Tác dụng
Fadefade-in-0 / fade-out-0mờ→rõ / rõ→mờ (-0 = từ opacity 0)
Zoomzoom-in-95 / zoom-out-95phóng từ 95% / thu về 95%
Slide ngangslide-in-from-left-2 / slide-in-from-right-4trượt vào từ trái/phải (số = bước spacing)
Slide dọcslide-in-from-top-2 / slide-in-from-bottom-2trượt vào từ trên/dưới
Slide raslide-out-to-top-2 / slide-out-to-bottom-2trượt ra hướng tương ứng
Spinspin-in-90 / spin-out-45xoay vào/ra (độ)

Và các utility chỉnh thời lượng / tiết tấu — chính là utility lõi của Tailwind, dùng lại nguyên xi:

UtilityTác dụng
duration-150duration-500thời lượng (ms)
delay-100trễ trước khi chạy
ease-out / ease-in-outđường cong tiết tấu
fill-mode-forwardsgiữ trạng thái cuối sau khi chạy
repeat-infinitelặp vô hạn
<!-- enter: mờ→rõ + phóng nhẹ từ 95%, trong 200ms, tiết tấu ease-out -->
<div class="animate-in fade-in-0 zoom-in-95 duration-200 ease-out">
  Xuất hiện mượt
</div>

Ghép với data-[state] của Radix

Sức mạnh thật là gắn các utility này vào trạng thái do Radix điều khiển, để một phần tử tự animate cả lúc mở lẫn lúc đóng:

<!-- Nội dung dialog tự bật ra khi open, đóng vào khi closed -->
<div
  class="data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
         data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95
         duration-200"
>

</div>

Vì sao cần utility exit riêng: trình duyệt không animate phần tử trên đường bị gỡ khỏi DOM. Radix giữ phần tử lại thêm một nhịp (data-state="closed") đủ để animation animate-out chạy xong rồi mới unmount. Nếu bạn tự ẩn bằng hidden, hiệu ứng đóng sẽ bị “nuốt”. Đây là lý do dialog/menu nên dùng primitive của Radix thay vì tự toggle. Bạn sẽ thấy đúng mẫu này khi ghép dialog ở Phần 7–9.


5. Tự viết utility — @utility

v4 nâng custom utility thành công dân hạng nhất: định nghĩa một cái trong CSS và nó tự tham gia mọi variant (hover:, md:, dark:, data-*) y như utility có sẵn — điều mà @layer utilities của v3 làm không trọn.

Dạng tĩnh — một giá trị cố định

@import "tailwindcss";

/* cân chữ trên nhiều dòng cho gọn */
@utility text-balance {
  text-wrap: balance;
}

/* tắt highlight xanh khi chạm trên mobile */
@utility tap-transparent {
  -webkit-tap-highlight-color: transparent;
}

/* ẩn thanh cuộn mà vẫn cuộn được */
@utility no-scrollbar {
  scrollbar-width: none;
  &::-webkit-scrollbar {
    display: none;
  }
}
<h1 class="text-balance md:text-balance hover:tap-transparent">…</h1>

Dạng hàm — nhận tham số qua --value()

Mạnh hơn: bạn tạo một họ utility nhận giá trị động (như tab-2, tab-4) bằng dấu * và hàm --value():

/* tab-1, tab-2, tab-4… → tab-size: 1|2|4 */
@utility tab-* {
  tab-size: --value(integer);
}

/* khớp với token spacing trong theme: gutter-4 → dùng --spacing-4 */
@utility gutter-* {
  scroll-padding-inline: --value(--spacing- *);
}
<pre class="tab-4">…</pre>
Cú phápÝ nghĩa
@utility name { … }utility tĩnh, một giá trị
@utility name-* { … }họ utility nhận tham số
--value(integer)lấy phần sau dấu - như số nguyên
--value(--spacing- *)tra token spacing tương ứng trong theme
&::-webkit-scrollbar { … }dùng nested selector trong định nghĩa utility

Khi nào nên viết @utility thay vì class CSS thường? Khi bạn muốn nó kết hợp được với variant (md:no-scrollbar, hover:text-balance). Nếu chỉ cần một rule tĩnh không bao giờ đi với variant, một class CSS thường trong @layer cũng đủ — đừng phức tạp hóa.


6. Tự viết variant — @custom-variant

Nếu @utility phát minh thuộc tính, thì @custom-variant phát minh điều kiện — bất kỳ tiền tố trạng thái nào bạn cần, để dùng được với mọi utility. Ta đã gặp nó cho dark mode ở Phần 3; đây là dạng tổng quát.

/* style khi chính phần tử có aria-current="page" */
@custom-variant current (&[aria-current="page"]);

/* style khi nằm trong một vùng [data-theme="brand"] */
@custom-variant brand (&:where([data-theme="brand"] *));

/* dark mode theo class thay vì media query */
@custom-variant dark (&:where(.dark, .dark *));

/* nhắm trạng thái mở của Radix (rút gọn data-[state=open]) */
@custom-variant open (&[data-state="open"]);
<a class="text-slate-500 current:text-indigo-500 current:font-semibold" aria-current="page">
  Home
</a>

<div data-state="open" class="opacity-0 open:opacity-100 open:animate-in">…</div>
Mẫu selectorNghĩa
(&[attr="x"])chính phần tử mang thuộc tính
(&:where(.scope *))phần tử nằm trong một scope (specificity 0)
(.parent &)phần tử có tổ tiên .parent
(&:hover, &:focus)gộp nhiều điều kiện vào một variant

Lợi ích: các trạng thái riêng của app (aria-*, data-* tùy biến, scope theme) trở thành khai báo ngay trong markup, thay vì rẽ nhánh className={isOpen ? ... : ...} rải khắp JS.

@custom-variant vs data-[...] trực tiếp: bạn có thể viết thẳng data-[state=open]:opacity-100 không cần khai báo gì. Hãy tạo @custom-variant khi điều kiện đó lặp lại nhiều nơi (đặt tên open: ngắn gọn, sửa một chỗ) hoặc khi selector phức tạp (:where(...)) mà bạn không muốn viết lại mỗi lần.


7. Hệ sinh thái component — chọn cái nào?

Plugin lo style. Còn hành vi (dialog đóng bằng Esc, menu điều hướng bằng phím, focus trap…) đến từ tầng component. Có bốn lựa chọn lớn, chia theo trục “headless (bạn tự style) ↔ pre-styled (đẹp sẵn)“:

  headless / bạn sở hữu style          pre-styled / đẹp sẵn
  ──────────────────────────────────────────────────────────►
  Headless UI   Radix UI        shadcn/ui      Flowbite   daisyUI
  (logic, ko    (logic + a11y   (copy code,    (component (component
   style)        sâu hơn)        Radix+TW)      TW sẵn)    = class TW)
Thư việnKiểuCấp gìStyle ra saoChọn khi
Headless UIheadlessmột số ít primitive (Menu, Dialog, Listbox, Tabs…)bạn tự style 100% bằng Tailwinddự án nhỏ, chỉ cần vài component, muốn ít phụ thuộc; làm bởi team Tailwind
Radix UIheadlessbộ primitive rộng & a11y rất sâu (WAI-ARIA, focus, keyboard)bạn tự style; phát data-[state] để hook animationcần dialog/menu/tooltip/popover… chuẩn accessibility, tự kiểm soát giao diện (pillar của series này)
shadcn/uicode bạn sở hữucomponent dựng sẵn = Radix + Tailwind, copy vào repođã style sẵn, bạn sửa thẳng codemuốn đẹp ngay mà vẫn sở hữu & tùy biến từng dòng; không muốn “đen hộp” npm
daisyUIplugin pre-styledthêm class component (btn, card, modal) + themeđổi theme bằng biến; ít phải viết utilitylàm nhanh, prototype, thích kiểu “class component” giống Bootstrap
Flowbitethư viện pre-styledcomponent + một ít JS (qua data-attr)TW sẵn, theme hạn chế hơncần nhiều block/landing dựng sẵn, chấp nhận ít tùy biến hơn

Cách nghĩ thực dụng:

  • Cần kiểm soát giao diện + accessibility chuẩn → Radix (headless) + Tailwind. Đây chính là hướng của series.
  • Cần đẹp ngay nhưng vẫn sở hữu code → shadcn/ui (Radix + Tailwind, copy vào repo).
  • Cần dựng siêu nhanh, không kén pixel → daisyUI hoặc Flowbite.
  • Chỉ thiếu vài primitive → Headless UI cho nhẹ.

Vì sao series chọn Radix + shadcn thay vì daisyUI/Flowbite: với sản phẩm thật, bạn gần như luôn cần tùy biến giao diện theo brandkiểm soát accessibility. Pre-styled nhanh lúc đầu nhưng sẽ “chống lại bạn” khi thiết kế lệch khỏi mặc định của nó. Headless + bạn-sở-hữu-style cho trần tùy biến cao hơn nhiều, đúng triết lý utility-first đã học từ Phần 1.


8. Phần còn lại của bộ đồ nghề pro

Ngoài plugin chức năng, vài plugin/công cụ “chất lượng cuộc sống” nên có ở mọi project nhóm:

Công cụTác dụngGhi chú
prettier-plugin-tailwindcsstự sắp xếp class theo thứ tự chuẩncài ngay từ đầu — chấm dứt tranh cãi “đặt class nào trước”, diff sạch
Tailwind CSS IntelliSenseautocomplete + cảnh báo class xung đột/saiextension VS Code, đã giới thiệu ở Phần 1
@tailwindcss/aspect-ratiotỉ lệ khung hìnhhiếm cần ở v4 vì aspect-* đã vào lõi; chỉ dùng cho legacy v3
@tailwindcss/container-queriescontainer queryở v4 @container/@min-* đã vào lõi; plugin chỉ cho v3

Quy tắc: trước khi cài một plugin, kiểm tra xem tính năng đó đã vào lõi v4 chưa. Nhiều thứ từng là plugin ở v3 (aspect-ratio, container query) nay đã có sẵn — cài plugin sẽ thừa và có thể xung đột.

Ma trận quyết định nhanh

Nhu cầuLựa chọn nên nghĩ tới trướcLý do
Style nội dung Markdown/MDX/CMS@tailwindcss/typographybạn không kiểm soát từng thẻ con
Form control native trong app tự build@tailwindcss/formsreset input/select/checkbox cho utility dễ ăn
Form dùng shadcn/uiKhông dùng forms pluginshadcn đã style từng component, tránh reset chồng
Dialog/menu/tooltip có animation enter/exittw-animate-css + Radix data-stateRadix giữ node cho exit animation
Một selector/pattern riêng lặp nhiều nơi@utility hoặc @custom-variantnhẹ hơn plugin ngoài, cùng ngôn ngữ v4
Cần component có hành vi phức tạpRadix/shadcnCSS plugin không giải quyết focus trap/keyboard/a11y
Prototype UI rất nhanh, ít yêu cầu branddaisyUI/Flowbiteđổi lấy tốc độ, chấp nhận ít kiểm soát hơn

Nếu bạn đang xây design system dài hạn, ưu tiên đường: Tailwind core → @utility/@custom-variant nội bộ → Radix/shadcn cho hành vi/component → plugin ngoài khi thật sự cần.


9. Gotchas thường gặp

Triệu chứngNguyên nhânCách xử lý
prose không ăn gìquên @plugin "@tailwindcss/typography", hoặc class prose không nằm trên phần tử bao nội dungđặt prose lên thẻ cha bọc toàn bộ HTML render ra
Bài viết bị bó hẹp giữa trangprose mặc định max-width: 65chthêm max-w-none nếu layout đã có cột
dark:prose-invert không đổi màuthiếu cơ chế dark (class .dark hoặc media) đã set ở Phần 3đảm bảo variant dark hoạt động trước
Style <select> vẫn xấuquên @plugin "@tailwindcss/forms", hoặc đang ở strategy: class mà thiếu form-selectnạp plugin, hoặc thêm class form-*
forms “đánh nhau” với shadcncài cả @tailwindcss/forms lẫn shadcn (shadcn tự style input)chọn một hướng; với shadcn thường bỏ plugin forms
Hiệu ứng đóng dialog bị mấttự ẩn bằng hidden/unmount ngay, animation animate-out không kịp chạydùng primitive Radix giữ phần tử qua data-state="closed"
animate-in không chạythiếu @import "tw-animate-css" (v4) hoặc dùng tên class v3 trong dự án v4nạp đúng package theo phiên bản
@utility báo lỗi parseđặt nó trên dòng @import "tailwindcss"luôn khai báo @utility/@plugin/@custom-variant sau @import
Plugin v3 (require(...)) không chạy ở v4v4 không tự đọc mảng plugins của config JSchuyển sang @plugin trong CSS, hoặc @config trỏ tới file cũ

Hai lỗi hay gặp nhất: (1) quên nạp plugin rồi tưởng class hỏng — luôn kiểm tra dòng @plugin/@import đầu file CSS; (2) thứ tự — mọi directive mở rộng phải nằm sau @import "tailwindcss".


10. Recipe thực tế

Recipe 1 — Style nội dung Markdown/MDX bằng prose

Tình huống điển hình: một route blog nhận HTML đã render từ Markdown/MDX và cần trông đẹp ngay, cả light lẫn dark.

/* app.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));
// BlogPost.tsx — dangerouslySetInnerHTML chỉ để minh hoạ; nguồn HTML phải tin cậy/đã sanitize
export function BlogPost({ html }: { html: string }) {
  return (
    <article
      className="prose prose-slate dark:prose-invert max-w-none
                 prose-headings:scroll-mt-24 prose-headings:font-semibold
                 prose-a:text-indigo-600 prose-a:no-underline hover:prose-a:underline
                 prose-img:rounded-xl prose-pre:rounded-xl prose-pre:bg-slate-900"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

Điểm cần nhớ: max-w-none để khớp layout, prose-headings:scroll-mt-24 để anchor không bị header dính che, và override prose-a:/prose-pre: cho khớp brand.

Bảo mật: dangerouslySetInnerHTML chèn HTML thô — chỉ dùng với nguồn bạn kiểm soát hoặc đã sanitize (vd rehype-sanitize). HTML từ người dùng chưa lọc là cửa ngõ XSS.

Recipe 2 — Dialog tự animate enter/exit (Radix + tw-animate-css)

@import "tailwindcss";
@import "tw-animate-css";
import * as Dialog from "@radix-ui/react-dialog";

export function Modal() {
  return (
    <Dialog.Root>
      <Dialog.Trigger className="rounded-md bg-indigo-600 px-4 py-2 text-white">
        Mở
      </Dialog.Trigger>
      <Dialog.Portal>
        {/* overlay: chỉ fade */}
        <Dialog.Overlay
          className="fixed inset-0 bg-black/50
                     data-[state=open]:animate-in data-[state=open]:fade-in-0
                     data-[state=closed]:animate-out data-[state=closed]:fade-out-0"
        />
        {/* content: fade + zoom nhẹ, canh giữa */}
        <Dialog.Content
          className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
                     rounded-xl bg-white p-6 shadow-xl duration-200
                     data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
                     data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
        >
          <Dialog.Title className="text-lg font-semibold">Tiêu đề</Dialog.Title>
          <Dialog.Description className="mt-2 text-sm text-slate-600">
            Nội dung dialog.
          </Dialog.Description>
          <Dialog.Close className="mt-4 rounded-md border px-3 py-1.5 text-sm">
            Đóng
          </Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Chính mẫu data-[state=open]:animate-in … data-[state=closed]:animate-out … này là “bí mật” đằng sau cảm giác mượt của shadcn — và Radix lo phần giữ phần tử lại đủ lâu để exit chạy xong.

Recipe 3 — Gói cụm prefix dài thành một utility

Khi một cụm data-[state=...]:animate-* lặp ở nhiều component, gói nó lại bằng @utility cho gọn:

@import "tailwindcss";
@import "tw-animate-css";

/* một utility gói trọn enter+exit kiểu "popover" */
@utility anim-pop {
  &[data-state="open"] {
    animation: enter 200ms ease-out;
  }
  &[data-state="closed"] {
    animation: exit 150ms ease-in;
  }
}
<div data-state="open" class="anim-pop">…</div>

11. Bài tập

1. Nạp plugin typography ở v4 và render một bài Markdown bằng prose đảo màu khi dark mode.

Lời giải
@import "tailwindcss";
@plugin "@tailwindcss/typography";
<article class="prose dark:prose-invert">…</article>

2. Viết một @utility tên text-pretty đặt text-wrap: pretty.

Lời giải
@utility text-pretty { text-wrap: pretty; }

Vì là @utility, nó tự đi được với variant: md:text-pretty, prose-p:text-pretty.

3. Tạo @custom-variant tên expanded áp khi phần tử có aria-expanded="true".

Lời giải
@custom-variant expanded (&[aria-expanded="true"]);
<button class="bg-slate-200 expanded:bg-indigo-500" aria-expanded="true">…</button>

4. Bài viết trong vùng prose đang bị bó hẹp ~65ký tự dù cột đã đủ rộng. Thêm gì để bỏ giới hạn, và vì sao có giới hạn đó?

Lời giải

Thêm max-w-none. prose mặc định đặt max-width: 65ch vì độ dài dòng ~50–75 ký tự là tối ưu cho việc đọc; khi layout đã kiểm soát bề rộng thì bỏ giới hạn này đi.

5. Cho overlay của một dialog Radix chỉ fade khi mở và đóng (không zoom). Viết chuỗi class.

Lời giải
<div class="fixed inset-0 bg-black/50
            data-[state=open]:animate-in data-[state=open]:fade-in-0
            data-[state=closed]:animate-out data-[state=closed]:fade-out-0"></div>

Overlay thường chỉ fade; phần content mới thêm zoom-in-95/zoom-out-95.

6. Bạn dùng cụm data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 ở năm component khác nhau. Tên modifier nào (định nghĩa bằng @custom-variant) giúp rút gọn, và viết khai báo đó.

Lời giải

Tạo một variant open rồi viết open:animate-in open:fade-in-0 open:zoom-in-95:

@custom-variant open (&[data-state="open"]);

Sửa selector một chỗ, áp dụng cho cả năm component.

Nâng cao: trong tab animate của showcase, xác định đúng hai utility tạo hiệu ứng “bật” của dialog shadcn — một fade-in-0 cộng một zoom-in-95 — và thử đổi duration-200 thành duration-500 để thấy tiết tấu chậm lại.


Điểm chính

  • Plugin = code chạy lúc build sinh ra utility/variant/base; v4 nạp bằng @plugin "..." trong CSS (v3 dùng mảng plugins JS). Plugin thuần-CSS thì nạp bằng @import. Mọi directive phải nằm sau @import "tailwindcss".
  • @tailwindcss/typography cho class prose để style nội dung không gắn class được (Markdown/MDX/CMS): có thang prose-sm…2xl, prose-invert cho dark, và element modifier prose-headings:/prose-a:/prose-pre:… ; nhớ max-w-none khi layout đã hẹp.
  • @tailwindcss/forms reset control form về nền thân thiện utility (chiến lược base mặc định hoặc class opt-in) — nhưng bỏ nó nếu dùng shadcn.
  • tailwindcss-animate/tw-animate-css cấp utility enter/exit (animate-in/out + fade/zoom/slide) ghép với data-[state] của Radix — nền tảng cho motion mượt của shadcn; Radix giữ phần tử để exit kịp chạy.
  • v4 cho bạn tự viết utility (@utility, kể cả dạng hàm name-* + --value()) và variant (@custom-variant) ngay trong CSS — biến trạng thái riêng của app thành class kết hợp được mọi variant.
  • Bản đồ hệ sinh thái: Headless UI (vài primitive nhẹ) · Radix (headless, a11y sâu) · shadcn/ui (Radix+TW, bạn sở hữu code) · daisyUI/Flowbite (pre-styled, nhanh nhưng ít tùy biến). Series chọn Radix + shadcn để vừa đẹp vừa kiểm soát giao diện & accessibility.

Tiếp theo

Ta đã thành thạo Tailwind. Phần 7 — Radix UI primitives mở pillar thứ hai: hành vi headless, dễ tiếp cận. Ta sẽ cài Radix, hiểu vì sao “headless” quan trọng, và dựng một Dialog và Dropdown dễ tiếp cận hoàn chỉnh — style bằng tất cả những gì bạn vừa học, kể cả các utility animate ở Phần này.