Tailwind, Radix & shadcn/ui · Part 13 — Tailwind Variants Deep Reference
Tổng hợp toàn bộ cơ chế variant trong Tailwind CSS v4: hover, focus, responsive, dark mode, group, peer, has, data/aria, arbitrary variant và cách dùng trong Radix/shadcn.
Khi mới dùng Tailwind, thứ gây bối rối nhất không phải utility class — mà là đống prefix kiểu hover:, md:, dark:, group-hover:, data-[state=open]:,… đặt trước class. Tailwind gọi chung tất cả chúng là variant.
Đây là bài tra cứu đầy đủ nhất về variant trong cả series — một chỗ duy nhất gom mọi họ variant, kèm bảng tra, selector CSS sinh ra, cơ chế bên dưới, gotchas và công thức thực chiến. Đọc xong, bạn không cần mở docs Tailwind cho phần variant nữa. Ta đi từ gốc lên ngọn: variant là gì, tại sao gọi như vậy, selector được sinh ra thế nào, rồi từng họ variant một.
Bài này quan hệ thế nào với Phần 4? Phần 4 (Variants, States & Composition) dạy variant trong dòng chảy của series. Bài này là tài liệu tham chiếu độc lập, sâu hơn — đủ mọi nhánh, mọi bảng. Không mâu thuẫn với Phần 4; nó là bản “tra cứu trọn đời”.
1. “Variant” nghĩa là gì?
Từ “variant” nghĩa là biến thể. Xét class bg-sky-500 — đây là bản gốc, luôn áp dụng:
.bg-sky-500 {
background-color: var(--color-sky-500);
}
Còn hover:bg-sky-500 là biến thể của nó — cùng style, nhưng chỉ kích hoạt khi có điều kiện:
.hover\:bg-sky-500:hover {
background-color: var(--color-sky-500);
}
Hai class cùng output CSS, chỉ khác điều kiện áp dụng. Phần hover: chính là variant — biến thể có điều kiện.
Điểm quan trọng: variant không chỉ là state. Tất cả các prefix tạo điều kiện đều là variant:
hover:bg-sky-500 → biến thể "khi hover"
md:bg-sky-500 → biến thể "khi màn hình ≥ 768px"
dark:bg-sky-500 → biến thể "khi dark mode"
first:bg-sky-500 → biến thể "khi là first-child"
disabled:bg-sky-500 → biến thể "khi disabled"
Cùng 1 utility, khác điều kiện kích hoạt → variant. Nếu gọi hover: là “pseudo-class prefix” thì md: gọi là gì? dark: gọi là gì? Từ “variant” bao trùm hết tất cả — đó là lý do Tailwind chọn một cái tên duy nhất cho mọi loại tiền tố điều kiện.
2. Giải phẫu cú pháp & cơ chế sinh selector
Một variant là tiền tố đặt trước utility, ngăn cách bằng dấu hai chấm:
hover:bg-indigo-600
─┬─── ──────┬──────
│ └── utility (áp khi điều kiện đúng)
└── variant (điều kiện)
Tailwind biến tiền tố thành CSS như thế nào
Variant không ghi đè utility gốc — nó sinh ra một rule CSS riêng, có điều kiện chọn lọc cao hơn. Mỗi tiền tố ánh xạ sang một trong ba thứ:
| Loại variant | Tiền tố ví dụ | Bọc utility thành |
|---|---|---|
| Pseudo-class / selector | hover:, first:, checked: | &:hover { … } |
| At-rule (media/supports) | md:, dark:, print: | @media (min-width: 48rem) { … } |
| Bộ chọn quan hệ | group-hover:, peer-checked: | .group:hover & { … } |
Bên dưới, hover:bg-indigo-600 được Tailwind biên dịch thành:
.hover\:bg-indigo-600:hover {
background-color: var(--color-indigo-600);
}
Ba chi tiết cơ chế giúp bạn khỏi bối rối về sau:
- Dấu
:trong tên class bị escape thành\:trong CSS sinh ra (.hover\:bg-indigo-600).:là ký tự đặc biệt của CSS selector, nên phải thoát — nhờ vậyhover:bg-indigo-600mới là tên class hợp lệ. - Variant chỉ là một map prefix → selector/at-rule.
hover:→&:hover,md:→@media (min-width: 48rem),group-hover:→.group:hover &. Không có ma thuật, chỉ là tra bảng rồi bọc. - Mọi variant đều đứng trước bất kỳ utility nào.
hover:không riêng cho màu:hover:scale-105,hover:shadow-lg,focus:w-64đều hợp lệ. Đây là khác biệt cốt lõi với framework CSS cũ vốn chỉ có sẵn vài state cố định.
Quy tắc vàng: một variant = một utility
Variant áp cho ĐÚNG MỘT utility đứng ngay sau nó. Muốn đổi 3 thuộc tính khi hover thì phải lặp tiền tố 3 lần:
hover:bg-indigo-600 hover:text-white hover:shadow-lg. Không có cú pháp “gom nhóm” kiểuhover:{...}. Quên lặp tiền tố là lỗi #1 của người mới — “sao hover chỉ đổi màu mà không scale?“.
<!-- ❌ chỉ bg đổi khi hover; text & shadow áp VÔ ĐIỀU KIỆN -->
<button class="text-white shadow-lg hover:bg-indigo-600">Sai</button>
<!-- ✅ lặp tiền tố cho từng thuộc tính cần đổi khi hover -->
<button class="hover:bg-indigo-600 hover:text-white hover:shadow-lg">
Đúng
</button>
3. Nhóm pseudo-class: trạng thái tương tác & form
Đây là họ variant gặp hằng ngày — style element dựa trên trạng thái tương tác chuột/bàn phím và trạng thái form. Class hover:bg-blue-700 không làm gì ở trạng thái bình thường, chỉ kích hoạt khi hover. Đây là khác biệt lớn so với CSS truyền thống (1 class chứa cả default lẫn hover).
3.1 Variant tương tác (pointer & keyboard)
| Variant | Selector sinh ra | Kích hoạt khi | Dùng cho |
|---|---|---|---|
hover: | :hover | con trỏ trên phần tử | gợi ý tương tác (affordance) |
focus: | :focus | được focus (chuột hoặc bàn phím) | ring/outline |
focus-visible: | :focus-visible | focus qua bàn phím (ẩn khi click chuột) | ring a11y “đúng lúc” |
focus-within: | :focus-within | chính nó hoặc con đang focus | làm nổi cả khối form |
active: | :active | đang nhấn giữ | phản hồi xúc giác (scale nhẹ) |
visited: | :visited | link đã ghé (chỉ vài thuộc tính màu) | màu link đã đọc |
target: | :target | id phần tử khớp hash URL | highlight mục được nhảy tới |
3.2 Variant trạng thái form
| Variant | Selector | Kích hoạt khi | Dùng cho |
|---|---|---|---|
disabled: | :disabled | có thuộc tính disabled | làm mờ + khóa control |
enabled: | :enabled | control bật (mặc định) | đảo ngược của disabled |
checked: | :checked | checkbox/radio được tích | toggle, custom checkbox |
indeterminate: | :indeterminate | checkbox “lửng" | "chọn tất cả” một phần |
default: | :default | option/radio mặc định của form | đánh dấu lựa chọn gốc |
required: | :required | input có required | dấu sao bắt buộc |
optional: | :optional | input không required | nhãn “tùy chọn” |
valid: | :valid | giá trị hợp lệ theo constraint | viền xanh |
invalid: | :invalid | giá trị sai constraint | viền đỏ, báo lỗi |
user-valid: | :user-valid | hợp lệ sau khi người dùng tương tác | lỗi không nhảy ra sớm |
user-invalid: | :user-invalid | sai sau khi đã chạm vào | UX báo lỗi lịch sự hơn |
in-range: | :in-range | số trong min–max | slider/number hợp lệ |
out-of-range: | :out-of-range | số ngoài khoảng | cảnh báo vượt giới hạn |
placeholder-shown: | :placeholder-shown | input đang trống | floating label (mục 19) |
autofill: | :autofill | trình duyệt tự điền | sửa nền vàng autofill |
read-only: | :read-only | có readonly | style trường chỉ-đọc |
Một nút “đủ trạng thái” — mỗi state là một (hoặc vài) tiền tố, lặp lại theo từng thuộc tính:
<button
class="rounded-md bg-indigo-500 px-4 py-2 text-white transition hover:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400 active:scale-95 disabled:pointer-events-none disabled:opacity-40"
>
Save changes
</button>
Một input “đủ validation” — phản ứng theo state mà không cần JS:
<input
class="rounded-md border border-slate-300 px-3 py-2 required:border-red-300 user-invalid:border-pink-500 disabled:pointer-events-none disabled:opacity-50"
/>
Luôn có focus style — và nên là
focus-visible:.focus:hiện ring cả khi bấm chuột (thường thừa, trông rối).focus-visible:chỉ hiện khi điều hướng bằng bàn phím — đúng đối tượng cần nó. Một dòngfocus-visible:outline-2là khoản đầu tư a11y rẻ nhất bạn có.
user-invalid:>invalid:cho form thật.invalid:bật ngay khi trang load (ô trốngrequiredđã “invalid” rồi), nên báo đỏ chói trước cả khi người dùng gõ.user-invalid:chỉ bật sau khi họ đã tương tác rồi rời ô — đúng nhịp tâm lý.
4. focus vs focus-visible vs focus-within
Ba variant này hay bị nhầm. Khác biệt nằm ở cái gì đang focus và cách focus tới:
click chuột Tab bàn phím con bên trong focus
─────────── ───────────── ────────────────────
focus: ✅ bật ✅ bật ❌
focus-visible: ❌ (im) ✅ bật ❌
focus-within: ✅ (nếu là con) ✅ (nếu là con) ✅ bật
focus:— phần tử chính nó nhận focus, bất kể chuột hay bàn phím.focus-visible:— như trên nhưng chỉ khi trình duyệt cho rằng nên hiện ring (chủ yếu là điều hướng bàn phím). Đây là mặc định nên dùng cho ring.focus-within:— chính nó hoặc bất kỳ con cháu nào đang focus. Tuyệt vời để làm nổi cả một khối khi con trỏ vào input bên trong:
<!-- cả khung search sáng lên khi input bên trong được focus -->
<div
class="flex items-center rounded-lg border border-slate-300 px-3 focus-within:border-indigo-500 focus-within:ring-2 focus-within:ring-indigo-200"
>
<svg class="h-4 w-4 text-slate-400">🔍</svg>
<input
class="border-0 bg-transparent p-2 outline-none"
placeholder="Search…"
/>
</div>
5. Variant cấu trúc — vị trí trong danh sách
Khi render list, bạn thường cần style phần tử đầu/cuối/chẵn/lẻ mà không thêm class thủ công cho từng cái. Đây là các pseudo-class cấu trúc:
| Variant | Selector | Chọn |
|---|---|---|
first: | :first-child | con đầu tiên |
last: | :last-child | con cuối cùng |
only: | :only-child | con duy nhất |
odd: | :nth-child(odd) | con lẻ (1,3,5…) |
even: | :nth-child(even) | con chẵn (2,4,6…) |
first-of-type: | :first-of-type | đầu tiên cùng loại tag |
last-of-type: | :last-of-type | cuối cùng cùng loại tag |
only-of-type: | :only-of-type | duy nhất cùng loại tag |
empty: | :empty | phần tử không có con/nội dung |
nth-[3n+1]: | :nth-child(3n+1) | công thức nth tùy ý |
nth-last-[2]: | :nth-last-child(2) | đếm từ cuối |
Ví dụ kinh điển: list bo góc trên/dưới ở hai đầu, nền xen kẽ, viền giữa các hàng nhưng không viền dưới hàng cuối:
<ul class="divide-y divide-slate-200 rounded-lg border">
<li class="p-3 first:rounded-t-lg last:rounded-b-lg odd:bg-slate-50">
Row 1
</li>
<li class="p-3 odd:bg-slate-50">Row 2</li>
<li class="p-3 odd:bg-slate-50">Row 3</li>
</ul>
empty: đặc biệt hữu ích để ẩn container rỗng (ví dụ vùng thông báo lỗi chưa có nội dung) mà không cần JS:
<!-- biến mất hoàn toàn khi không có lỗi -->
<p class="mt-1 text-sm text-red-500 empty:hidden"></p>
empty:rất khắt khe: chỉ khớp khi phần tử tuyệt đối không có gì — kể cả một khoảng trắng hay xuống dòng cũng làm nó không rỗng. Khi nhồi nội dung bằng JS, hãy chắc là đặt chuỗi rỗng''chứ không phải' '.
6. Variant pseudo-element
Variant không chỉ cho pseudo-class (trạng thái) mà còn cho pseudo-element (phần ảo của phần tử): tạo nội dung trang trí, custom marker, đổi màu vùng bôi đen, style nút chọn file…
| Variant | Pseudo-element | Dùng cho |
|---|---|---|
before: | ::before | chèn nội dung trước (icon, dấu) |
after: | ::after | chèn nội dung sau (badge, gạch) |
placeholder: | ::placeholder | màu/kiểu chữ placeholder |
selection: | ::selection | màu nền/chữ khi bôi đen text |
marker: | ::marker | dấu đầu dòng của <li> |
first-line: | ::first-line | dòng đầu của đoạn |
first-letter: | ::first-letter | chữ cái đầu (drop cap) |
file: | ::file-selector-button | nút “Choose file” của input file |
backdrop: | ::backdrop | nền mờ phía sau <dialog> |
<!-- dấu sao đỏ trước label bắt buộc, tạo bằng ::before -->
<label class="before:mr-1 before:text-red-500 before:content-['*']"
>Email</label
>
<!-- placeholder nhạt + nghiêng, vùng bôi đen tím -->
<input
class="selection:bg-indigo-200 placeholder:text-slate-400 placeholder:italic"
placeholder="you@example.com"
/>
<!-- bullet tùy biến màu -->
<ul class="list-disc pl-5 marker:text-indigo-500">
<li>Một</li>
<li>Hai</li>
</ul>
<!-- style nút chọn file -->
<input
type="file"
class="file:mr-3 file:rounded file:border-0 file:bg-indigo-50 file:px-3 file:py-1.5 file:text-indigo-600"
/>
Bẫy kinh điển —
::before/::aftercầncontent. Hai pseudo-element này không hiện nếu thiếu thuộc tínhcontent, dù bạn đã set kích thước/màu. Luôn kèmbefore:content-[''](chuỗi rỗng cho khối trang trí thuần) hoặcafter:content-['*'](nội dung thật). Đây là lý dobefore:block before:h-2 before:w-2 before:bg-red-500im lặng không vẽ gì — thiếubefore:content-[''].
7. Variant responsive
Tailwind dùng mobile-first breakpoints: class không prefix = mọi kích thước. Prefix áp dụng từ breakpoint đó trở lên.
| Prefix | Min-width | Tương ứng |
|---|---|---|
| (không) | 0px | Mobile |
sm: | 640px | Tablet nhỏ |
md: | 768px | Tablet |
lg: | 1024px | Laptop |
xl: | 1280px | Desktop |
2xl: | 1536px | Desktop lớn |
<!-- Dọc trên mobile → ngang từ md trở lên -->
<div class="flex flex-col md:flex-row"></div>
<!-- 1 cột → 2 cột → 3 cột -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"></div>
<!-- Ẩn trên mobile, hiện từ md -->
<nav class="hidden md:block"></nav>
Tư duy đúng
Không prefix = mặc định cho mobile. Các prefix chỉ là override cho màn hình lớn hơn. Đừng cố target “chỉ mobile” — hãy nghĩ “mobile trước, rồi mở rộng dần”.
Giới hạn khoảng & breakpoint tùy ý
| Cú pháp | Nghĩa |
|---|---|
md:flex | từ md trở lên |
max-md:flex | dưới md (đến hết) |
md:max-lg:flex | từ md đến dưới lg (một khoảng) |
min-[900px]:grid-cols-2 | từ đúng 900px trở lên (giá trị tùy ý) |
max-[1100px]:hidden | dưới 1100px |
<!-- Chỉ áp dụng từ md đến dưới lg -->
<div class="md:max-lg:flex"></div>
<!-- Breakpoint tùy ý không có trong thang -->
<div class="grid min-[900px]:grid-cols-2"></div>
8. Variant dark mode
<div class="bg-white dark:bg-gray-800">
<h3 class="text-gray-900 dark:text-white">Title</h3>
<p class="text-gray-500 dark:text-gray-400">Description</p>
</div>
Mặc định dark: dùng media query prefers-color-scheme — theo setting OS. Muốn toggle thủ công bằng JS (thêm class .dark trên <html>), khai báo một custom variant trong CSS:
@import 'tailwindcss';
/* dark: giờ kích hoạt khi có .dark ở tổ tiên, thay vì theo OS */
@custom-variant dark (&:where(.dark, .dark *));
Sau đó toggle bằng JS:
document.documentElement.classList.toggle('dark');
@custom-variantlà cơ chế tổng quát để tự định nghĩa variant của riêng bạn ở v4 — không chỉ cho dark mode. Ví dụ@custom-variant sidebar-open (&:where([data-sidebar=open] *));cho bạn dùngsidebar-open:...ở bất cứ đâu.
9. group-* — con phản ứng theo trạng thái của cha
Đôi khi một con cần đổi style khi một tổ tiên được hover/focus. Đánh dấu cha bằng class group, rồi gắn tiền tố group-* cho con. Bên dưới, group-hover: sinh ra selector .group:hover & — “khi .group được hover, áp cho phần tử này”.
<a
href="#"
class="group block rounded-lg border p-4 transition hover:border-indigo-400"
>
<h3 class="font-semibold text-slate-900 group-hover:text-indigo-600">
Read the docs
<span class="inline-block transition group-hover:translate-x-1">→</span>
</h3>
<p class="mt-1 text-sm text-slate-500 group-hover:text-slate-700">
Mọi thứ cần biết.
</p>
</a>
Hover bất kỳ đâu trên card và cả tiêu đề lẫn mũi tên cùng phản ứng — dù con trỏ không trực tiếp trên chúng. Mọi pseudo-class đều có bản group-*: group-focus:, group-active:, group-disabled:, group-data-[state=open]:…
Named group — khi group lồng nhau
Nếu có group bên trong group, group-hover: sẽ phản ứng với cha gần nhất bất kỳ — gây nhầm. Giải pháp: đặt tên group bằng group/tên, rồi tham chiếu đúng tên bằng group-hover/tên::
<div class="group/card rounded-xl border p-4">
<button class="group/btn flex items-center gap-1">
<span class="group-hover/card:text-slate-900">Theo card</span>
<svg class="group-hover/btn:translate-x-0.5">→</svg>
</button>
</div>
group-hover/card: chỉ nghe card; group-hover/btn: chỉ nghe nút — không còn lẫn lộn dù lồng sâu.
10. peer-* — anh em phản ứng theo anh em
peer là group cho anh em ngang hàng, thường dùng với control form. Đánh dấu control là peer, rồi một anh em phản ứng. peer-checked: sinh ra .peer:checked ~ &.
<label class="flex items-center gap-2">
<input type="checkbox" class="peer sr-only" />
<span
class="h-5 w-5 rounded border peer-checked:border-emerald-500 peer-checked:bg-emerald-500"
></span>
<span class="text-slate-500 peer-checked:text-emerald-600">I agree</span>
</label>
Ràng buộc DOM bắt buộc nhớ:
peer-*chỉ style được phần tử đứng SAU peer trong DOM. Đó là vì CSS dùng bộ chọn anh em~(general sibling), vốn chỉ nhìn về phía sau. Không có “previous sibling selector” — nên thứ tự markup quyết định. Nếu nhãn phải hiện trước input về mặt thị giác nhưng cần nghe peer, hãy để input trước trong DOM rồi đảo lại bằngorder-*của flexbox.
DOM: <input class="peer"> <p class="peer-invalid:block"> ✅ p đứng SAU → OK
DOM: <p class="peer-invalid:block"> <input class="peer"> ❌ p đứng TRƯỚC → KHÔNG ăn
Cũng như group, peer đặt tên được: peer/email, peer-invalid/email: — cần khi nhiều peer trong cùng phạm vi.
11. has-* — cha tự đổi theo con (parent selector)
Suốt nhiều năm CSS không có “parent selector”. :has() thay đổi điều đó, và Tailwind phơi nó qua has-*. Khác với group/peer (đánh dấu phần tử khác rồi style phần tử này), has-* để một phần tử tự style chính nó dựa trên thứ nó CHỨA.
Hai dạng cú pháp
Shorthand — cho pseudo-class phổ biến, không cần ngoặc vuông:
has-checked:bg-blue-50 → :has(:checked)
has-focus:ring-2 → :has(:focus)
has-disabled:opacity-50 → :has(:disabled)
has-invalid:border-red-500 → :has(:invalid)
Arbitrary — cho mọi selector khác, dùng ngoặc vuông:
has-[img]:p-0 → :has(img)
has-[a]:underline → :has(a)
has-[:focus]:ring-2 → :has(:focus) ← tương đương has-focus
has-[input:checked]:bg-blue → :has(input:checked)
has-[>img]:overflow-hidden → :has(> img)
<!-- label tự đổi viền/nền khi checkbox BÊN TRONG nó được tích — không cần peer -->
<label
class="flex gap-2 rounded-lg border p-4 has-[:checked]:border-indigo-500 has-[:checked]:bg-indigo-50"
>
<input type="checkbox" class="mt-1" />
<span>Gửi cho tôi bản tin</span>
</label>
<!-- form group đổi màu khi chứa input invalid -->
<div class="has-[:invalid]:text-red-600">
<label>Email</label>
<input type="email" required />
</div>
<!-- chọn theo cấu trúc: card nào CÓ ảnh thì bố cục khác -->
<article
class="p-4 has-[img]:grid has-[img]:grid-cols-[auto_1fr] has-[img]:gap-4"
>
…
</article>
Kết hợp group-has-* / peer-has-*
has-* ghép được với group/peer: cho phép một phần tử phản ứng khi cha hoặc anh em của nó chứa thứ gì đó.
<!-- icon trong sidebar item sáng lên khi item (group) chứa một link active -->
<div class="group rounded-lg p-2">
<svg class="text-slate-400 group-has-[a.active]:text-indigo-500">●</svg>
<a href="#" class="active">Dashboard</a>
</div>
<!-- ẩn dot khi checkbox trong peer được checked -->
<label class="peer"> <input type="checkbox" checked /> Task 1 </label>
<svg class="peer-has-checked:hidden">●</svg>
Cách đọc nhanh has-*
has-[:focus] → "bên trong mình có gì đang focus?"
has-[img] → "bên trong mình có thẻ img?"
has-checked → "bên trong có gì đang checked?" (shorthand)
group-has-[a] → "bên trong group cha có thẻ <a>?"
peer-has-checked → "bên trong peer anh em có gì checked?"
12. So sánh 3 hệ thống: has vs group vs peer
Ba cơ chế trả lời cùng một câu hỏi — “style của phần tử ĐANG XÉT phụ thuộc trạng thái của AI?” — nhưng theo ba hướng quan hệ khác nhau:
┌──────────────────────────────────────────────────────────┐
│ Style của phần tử ĐANG XÉT bị chi phối bởi trạng thái… │
└──────────────────────────────────────────────────────────┘
group-* │ …của CHA/TỔ TIÊN (đánh dấu .group) cha ─▶ con
│ <div class="group ...">
│ <p class="group-hover:..."> ◀── style theo CHA
│ </div>
peer-* │ …của ANH EM ĐỨNG TRƯỚC (đánh dấu .peer) trước ─▶ sau
│ <input class="peer ...">
│ <p class="peer-checked:..."> ◀── phải ĐỨNG SAU peer
│ (sibling selector ~)
has-* │ …của CON/HẬU DUỆ bên trong CHÍNH NÓ con ─▶ cha
│ <label class="has-[:checked]:..."> ◀── cha tự đổi
│ <input type="checkbox"> theo CON bên trong
│ </label>
────────────────────────────────────────────────────────────────────────
Mẹo nhớ: group = nhìn LÊN cha • peer = nhìn NGANG (về sau)
has = nhìn XUỐNG con
| Phần tử đánh dấu | Phần tử được style | Hướng | Selector CSS | |
|---|---|---|---|---|
group-* | tổ tiên (.group) | hậu duệ | cha → con | .group:STATE & |
peer-* | anh em trước (.peer) | anh em sau | trước → sau | .peer:STATE ~ & |
has-* | — (tự nó) | chính nó | con → cha | &:has(SELECTOR) |
13. data-[...] & aria-[...] — chìa khóa cho Radix & shadcn
Đây là cầu nối tới phần còn lại của series. Các primitive Radix không tự style — chúng phơi trạng thái nội bộ ra DOM dưới dạng data attribute và ARIA attribute, rồi để bạn style bằng variant thuộc tính.
Cú pháp tổng quát:
data-[state=open]:rotate-180 → [data-state="open"] { ... }
data-[disabled]:opacity-50 → [data-disabled] { ... } (chỉ cần có attr)
aria-[expanded=true]:bg-slate-100 → [aria-expanded="true"] { ... }
aria-checked:bg-indigo-500 → [aria-checked="true"] { ... } (rút gọn boolean)
Tailwind có sẵn rút gọn cho các ARIA boolean hay gặp: aria-checked:, aria-disabled:, aria-expanded:, aria-hidden:, aria-pressed:, aria-selected:, aria-required:, aria-invalid:. Với giá trị tùy ý thì dùng dạng đầy đủ aria-[...].
Một accordion trigger của Radix tự style theo data-state của chính nó:
<!-- Radix gán data-state="open" | "closed" — bạn chỉ việc style theo -->
<button
data-state="open"
class="flex w-full items-center justify-between py-3 data-[state=open]:text-indigo-600"
>
Section title
<svg
class="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180"
>
▾
</svg>
</button>
Bảng các data-state/attribute mà bạn sẽ gặp đi gặp lại khi style Radix & shadcn:
| Attribute (Radix phát ra) | Giá trị | Có ở primitive |
|---|---|---|
data-state | open / closed | Accordion, Dialog, Popover, Dropdown, Collapsible |
data-state | checked / unchecked / indeterminate | Checkbox, Switch, Radio |
data-state | active / inactive | Tabs |
data-state | on / off | Toggle |
data-state | visible / hidden | Tooltip |
data-disabled | (chỉ có mặt) | hầu hết control |
data-highlighted | (chỉ có mặt) | item menu/select đang được trỏ |
data-side | top/right/bottom/left | nội dung Popover/Tooltip |
data-orientation | horizontal/vertical | Tabs, Separator, Slider |
aria-expanded | true/false | trigger Dropdown/Combobox |
aria-selected | true/false | item Select/Tabs |
<!-- shadcn-style: item menu nổi nền khi được trỏ phím, mờ khi disabled -->
<div
role="menuitem"
class="rounded px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-slate-100"
>
Profile
</div>
Đây là lý do Tailwind + Radix khớp nhau đẹp đến vậy. Radix lo hành vi và phát trạng thái ra attribute; Tailwind style từng trạng thái khai báo ngay tại markup. Không cần
useStatecho phần thị giác, không cần class toggle bằng JS — toàn bộ “open thì xoay, closed thì thẳng” sống trongclassName.
14. not-* variant — phủ định
Style khi điều kiện không đúng. not-* bọc điều kiện trong :not(...):
not-hover:opacity-70 → &:not(:hover)
not-first:border-t → &:not(:first-child)
not-focus:bg-white → &:not(:focus)
<!-- Hover effect chỉ khi KHÔNG focus -->
<button class="hover:not-focus:bg-indigo-700">Save</button>
<!-- Mọi item trừ item đầu có viền trên -->
<li class="not-first:border-t">…</li>
<!-- Style khi browser KHÔNG hỗ trợ grid -->
<div class="not-supports-[display:grid]:flex">Fallback layout</div>
15. Arbitrary variant — selector tùy ý
Khi không có variant dựng sẵn cho thứ bạn cần, viết thẳng selector trong ngoặc vuông. & đại diện cho chính phần tử; mọi selector CSS hợp lệ đều dùng được.
| Cú pháp | Sinh ra | Nghĩa |
|---|---|---|
[&>li]:mb-2 | & > li | mọi <li> con trực tiếp |
[&_p]:leading-relaxed | & p | mọi <p> hậu duệ (bất kỳ sâu nào) |
[&:nth-child(3)]:bg-slate-50 | &:nth-child(3) | pseudo-class tùy ý |
[&[data-active]]:font-bold | &[data-active] | theo attribute tùy ý |
[&::-webkit-scrollbar]:hidden | &::-webkit-scrollbar | pseudo-element vendor |
[@supports(backdrop-filter:blur(0))]:backdrop-blur | @supports(...) | feature query |
[@media(min-width:900px)]:flex | @media(...) | media query tùy ý |
group-[.is-open]:rotate-180 | .group.is-open & | group theo class tùy ý |
peer-[:nth-child(2)]:block | .peer:nth-child(2) ~ & | peer theo điều kiện tùy ý |
Phân tích [&>li]:rounded-lg
& = chính thẻ cha (ví dụ <ul>)
> = con trực tiếp
li = target
→ CSS: ul > li { border-radius: var(--radius-lg) }
Data attribute: shorthand vs arbitrary
<!-- Đầy đủ (arbitrary) -->
<button class="[&[data-state=open]]:bg-blue-500">A</button>
<!-- Tailwind shorthand — tương đương -->
<button class="data-[state=open]:bg-blue-500">B</button>
Hai dòng trên cho cùng CSS. Tailwind cung cấp data-[...] shorthand cho tiện.
Sibling combinator phức hợp
<div class="[&>[data-active]+span]:text-blue-600">
<span data-active>Tab active</span>
<span>Text này xanh ← đứng ngay SAU [data-active]</span>
<span>Text này không ← quá xa</span>
</div>
<!-- style con CMS-sinh mà không đụng từng con -->
<article
class="[&_a]:text-indigo-600 [&_a]:underline [&>h2]:mt-8 [&>h2]:text-xl"
>
{/* nội dung markdown bơm vào đây — mọi h2/a tự được style */}
</article>
<!-- ẩn thanh cuộn nhưng vẫn cuộn được -->
<div class="[scrollbar-width:none] overflow-auto [&::-webkit-scrollbar]:hidden">
…
</div>
Khoảng trắng trong arbitrary variant dùng
_(gạch dưới). Vì class không chứa được dấu cách, Tailwind quy ước_→ space. Nên[&_p]nghĩa là& p(hậu duệ), còn[&>p]là& > p(con trực tiếp). Nếu thật sự cần một gạch dưới trong giá trị (hiếm), escape bằng\_.
Đừng lạm dụng. Arbitrary variant là cửa thoát hiểm tiện lợi nhưng làm markup khó đọc. Nếu một selector tùy ý lặp ở nhiều nơi, hãy gói nó vào component hoặc một utility riêng thay vì rải
[&>...]khắp nơi.
16. Stack variant — xếp chồng nhiều điều kiện
Variant xếp chồng được: nối nhiều tiền tố để diễn đạt một điều kiện ghép. Cách đọc đúng là từ PHẢI sang TRÁI — utility ở ngoài cùng phải, các điều kiện bọc dần ra ngoài trái:
<!-- đọc: bg-slate-800 ÁP KHI (hover) VÀ (dark mode) VÀ (md trở lên) -->
<div class="md:dark:hover:bg-slate-800">…</div>
Bên dưới, mỗi tiền tố là một lớp bọc selector/at-rule lồng nhau:
md:dark:hover:bg-slate-800
@media (min-width: 48rem) { ← md:
.dark & { ← dark:
&:hover { ← hover:
background: #1e293b ← bg-slate-800
}
}
}
Vài lưu ý thực dụng:
- Với điều kiện độc lập (responsive + dark + hover), đổi thứ tự không đổi nghĩa — chúng AND với nhau. Nhưng quy ước đọc phải→trái giúp nhất quán trong team.
- Với variant cùng họ có thứ tự ngữ nghĩa (ví dụ
group-*lồnghas-*), thứ tự có ý nghĩa — viết theo quan hệ bao ngoài. - Giữ chuỗi ngắn — ba tiền tố là nhiều rồi. Cần sâu hơn thường là dấu hiệu nên tách thành component hoặc dời logic sang một
data-*duy nhất do JS điều khiển.
<!-- vài ví dụ stack hợp lệ -->
<button class="bg-blue-500 hover:bg-blue-700 disabled:hover:bg-blue-500">
Giữ màu khi disabled
</button>
<div class="group">
<p class="dark:group-hover:text-white">Adaptive text</p>
</div>
17. Áp nhiều property cho cùng 1 variant
Câu hỏi thường gặp: data-[state=open]: muốn đổi cả bg-, text-, rotate thì sao? Trả lời: lặp tiền tố cho mỗi utility. Tailwind không có cú pháp gom kiểu data-[state=open]:{...}.
<button
class="data-[state=open]:rotate-180 data-[state=open]:bg-blue-500 data-[state=open]:text-white"
>
Trigger
</button>
Khi quá dài (5–6+ property), chuyển sang custom CSS với @apply và nested selector:
/* index.css */
@layer components {
.accordion-trigger {
@apply px-4 py-2 transition-all;
&[data-state='open'] {
@apply rotate-180 rounded-lg bg-blue-500 font-semibold text-white;
}
}
}
Hoặc viết mỗi class một dòng trong JSX cho dễ đọc (với helper cn() từ Phần 5):
<AccordionTrigger
className={cn(
'transition-all',
'data-[state=open]:bg-blue-500',
'data-[state=open]:text-white',
'data-[state=open]:rotate-180',
'data-[state=open]:rounded-lg',
'data-[state=open]:font-semibold'
)}
/>
18. Sơ đồ quyết định: chọn variant nào?
Muốn style theo state tương tác?
├── Hover/focus/active → hover: / focus-visible: / active:
├── Form state → disabled: / invalid: / checked: / required: / user-invalid:
├── Vị trí trong list → first: / last: / odd: / even: / empty:
└── Phủ định → not-hover: / not-focus: / not-first: ...
Muốn style theo môi trường?
├── Kích thước màn hình → sm: / md: / lg: / xl:
├── Dark mode → dark:
├── Giới hạn khoảng → md:max-lg: / max-md:
└── Breakpoint tùy ý → min-[900px]:
Muốn style theo quan hệ element?
├── Theo state cha → group + group-hover: / group-data-[...]:
├── Theo state anh em → peer + peer-checked: / peer-focus:
├── Theo nội dung con → has-checked: / has-[img]:
├── Theo con của cha → group-has-[a]:
└── Theo con của anh em → peer-has-checked:
Muốn style theo attribute (Radix/shadcn)?
├── Data state → data-[state=open]: / data-[disabled]:
└── ARIA → aria-expanded: / aria-[...]:
Selector phức tạp, không có variant sẵn?
├── Target children → [&>li]: / [&_p]:
├── Pseudo-element vendor → [&::-webkit-scrollbar]:
├── Sibling combinator → [&+div]: / [&~span]:
└── @supports / @media → [@supports(...)]: / [@media(...)]:
19. Công thức thực chiến
Floating label (không JS)
Dùng placeholder-shown: để biết input còn trống, và peer-* để label phản ứng. Cho input một placeholder=" " (một dấu cách) để pseudo-class hoạt động.
<div class="relative">
<input
id="email"
type="email"
placeholder=" "
class="peer w-full rounded-md border border-slate-300 px-3 pt-5 pb-2 outline-none focus:border-indigo-500"
/>
<label
for="email"
class="absolute top-2 left-3 text-xs text-slate-500 transition-all peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-base peer-focus:top-2 peer-focus:text-xs peer-focus:text-indigo-600"
>
Email
</label>
</div>
Báo lỗi không JS (chỉ sau khi người dùng chạm)
<input
type="email"
required
placeholder=" "
class="peer rounded-md border border-slate-300 px-3 py-2 user-invalid:border-red-500"
/>
<p class="mt-1 hidden text-sm text-red-500 peer-[:user-invalid]:block">
Email không hợp lệ
</p>
peer-[:user-invalid]:block chỉ hiện báo lỗi sau khi người dùng đã tương tác — không nhảy đỏ ngay lúc load.
Custom radio/checkbox “card” (dùng has-*)
<label
class="flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition has-[:checked]:border-indigo-500 has-[:checked]:bg-indigo-50 has-[:checked]:ring-1 has-[:checked]:ring-indigo-500"
>
<input type="radio" name="plan" class="mt-1" />
<div>
<p class="font-medium">Pro</p>
<p class="text-sm text-slate-500">$12/tháng</p>
</div>
</label>
Cả thẻ tự đổi viền + nền khi radio bên trong được chọn — đúng việc mà group/peer không làm được.
Accordion chevron (mẫu Radix/shadcn)
<button
data-state="open"
class="group flex w-full items-center justify-between py-3"
>
<span class="group-data-[state=open]:text-indigo-600"
>Câu hỏi thường gặp</span
>
<svg
class="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180"
>
▾
</svg>
</button>
Khi Radix đổi data-state thành open, cùng một attribute vừa đổi màu tiêu đề vừa xoay chevron. Một nguồn sự thật, hai hiệu ứng — đó là cách shadcn dựng toàn bộ component.
Dropdown thuần CSS bằng peer
<div class="relative">
<input type="checkbox" id="menu" class="peer hidden" />
<label for="menu" class="cursor-pointer rounded-md border px-3 py-1.5"
>Menu ▾</label
>
<ul
class="absolute mt-1 hidden rounded-md border bg-white shadow-lg peer-checked:block"
>
<li class="px-3 py-2 hover:bg-slate-50">Hồ sơ</li>
<li class="px-3 py-2 hover:bg-slate-50">Đăng xuất</li>
</ul>
</div>
20. Gotchas — bảng lỗi thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Hover chỉ đổi một thuộc tính | Quên lặp tiền tố cho từng property | hover:bg-x hover:text-y hover:shadow-z — mỗi cái một tiền tố |
peer-* không phản ứng | Phần tử style đứng trước peer trong DOM | Đặt phần tử sau peer; cần đảo thị giác thì dùng order-* |
before:/after: không vẽ gì | Thiếu content | Luôn kèm before:content-[''] |
data-[state=open]: không ăn | Sai cú pháp ngoặc / sai tên attribute | Kiểm tra DOM thật bằng DevTools; attr là data-state, value open |
Báo lỗi invalid: đỏ ngay khi load | :invalid đúng ngay lúc trang mở | Dùng user-invalid: thay cho invalid: |
group-hover: ăn nhầm group | Có group lồng nhau | Đặt tên: group/x + group-hover/x: |
empty:hidden không ẩn | Bên trong có khoảng trắng/xuống dòng | Đảm bảo nội dung là chuỗi rỗng '' tuyệt đối |
Chuỗi md:hover: “đảo lộn” | Hiểu sai chiều đọc | Đọc phải→trái: utility trước, điều kiện bọc dần ra ngoài |
Arbitrary [&_p] không khớp con | Nhầm _ (hậu duệ) với > (con trực tiếp) | _ = space = mọi hậu duệ; > = con trực tiếp |
Class động data-[state=${s}]: không sinh | Tailwind quét text tĩnh, không chạy JS | Viết đủ chuỗi cho từng giá trị, đừng ghép template string |
Hai lỗi chiếm phần lớn câu hỏi: (1) quên lặp tiền tố (“sao hover chỉ đổi màu mà không scale?”) và (2) peer sai thứ tự DOM (“sao label không nghe checkbox?”). Nhớ hai điều này là tránh được đa số bug variant.
21. Bài tập
1. Dựng nút: nền tối hơn khi hover, vòng focus rõ cho người dùng bàn phím, nhấn nhẹ xuống khi active, mờ + không bấm được khi disabled.
Lời giải
<button
class="rounded-md bg-indigo-500 px-4 py-2 text-white transition hover:bg-indigo-600 focus-visible:outline-2 focus-visible:outline-indigo-400 active:scale-95 disabled:pointer-events-none disabled:opacity-40"
>
Save
</button>Mỗi state lặp tiền tố theo từng thuộc tính. focus-visible: thay vì focus: để ring chỉ hiện với bàn phím.
2. Một card mà hover toàn card sẽ làm link “View →” hiện màu accent và mũi tên trượt phải.
Lời giải
<a class="group block rounded-lg border p-4">
<h3>Title</h3>
<span class="text-slate-400 group-hover:text-indigo-500">
View
<span class="inline-block transition group-hover:translate-x-1">→</span>
</span>
</a>Đánh dấu cha group; con dùng group-hover: — phản ứng dù con trỏ không trực tiếp trên chúng.
3. Một input email hiện báo đỏ “Invalid email” chỉ sau khi người dùng tương tác và giá trị sai — không JS.
Lời giải
<input
type="email"
required
placeholder=" "
class="peer rounded-md border px-3 py-2 user-invalid:border-red-500"
/>
<p class="mt-1 hidden text-sm text-red-500 peer-[:user-invalid]:block">
Invalid email
</p>Dùng user-invalid (không phải invalid) để khỏi báo đỏ ngay lúc load; báo lỗi phải đứng sau peer trong DOM.
4. Một thẻ “plan” (label) tự đổi viền + nền khi radio bên trong nó được chọn — không peer, không JS.
Lời giải
<label
class="flex gap-3 rounded-lg border p-4 has-[:checked]:border-indigo-500 has-[:checked]:bg-indigo-50"
>
<input type="radio" name="plan" />
<span>Pro — $12/tháng</span>
</label>has-[:checked]: để cha tự style theo con bên trong — đúng việc mà group/peer không làm được.
5. Trong accordion, dùng cùng một data-state để vừa đổi màu tiêu đề vừa xoay chevron 180°.
Lời giải
<button data-state="open" class="group flex w-full justify-between py-3">
<span class="group-data-[state=open]:text-indigo-600">Section</span>
<svg class="transition-transform group-data-[state=open]:rotate-180">▾</svg>
</button>Một nguồn sự thật (data-state), hai hiệu ứng — chính là mẫu shadcn dùng.
6. Một vùng <ul> nội dung do CMS sinh: làm mọi <li> con trực tiếp có khoảng cách dưới, và mọi <a> hậu duệ gạch chân màu indigo — chỉ bằng class trên <ul>.
Lời giải
<ul class="[&_a]:text-indigo-600 [&_a]:underline [&>li]:mb-2">
…
</ul>[&>li] = con trực tiếp (dùng >); [&_a] = hậu duệ bất kỳ (dùng _ = space).
7. Cấu hình dark: để toggle thủ công bằng class .dark trên <html> thay vì theo OS.
Lời giải
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));Sau đó document.documentElement.classList.toggle('dark') để bật/tắt. Mặc định dark: theo prefers-color-scheme; @custom-variant chuyển nó sang theo class.
Điểm chính
- Một variant là tiền tố áp utility có điều kiện; nó sinh ra một rule CSS riêng chứ không ghi đè. “Variant” bao trùm mọi loại điều kiện — state, responsive, dark, quan hệ element, attribute.
- Cơ chế: mỗi tiền tố là một map sang selector (
hover:→&:hover), at-rule (md:→@media), hoặc bộ chọn quan hệ (group-hover:→.group:hover &); dấu:được escape thành\:. - Variant áp cho đúng một utility — đổi nhiều thuộc tính thì phải lặp tiền tố cho từng cái.
- Đủ các họ: pseudo-class (tương tác + form), cấu trúc (
first/last/odd/empty/nth-[...]), pseudo-element (before/after/placeholder/marker…), responsive, dark mode, quan hệ (group/peer/has), attribute (data-[...]/aria-[...]), phủ định (not-*), và arbitrary ([&>li],[@supports...]). - Ba hệ quan hệ, ba hướng:
group-*nhìn lên cha,peer-*nhìn ngang anh em sau (ràng buộc thứ tự DOM),has-*nhìn xuống con. Ghép được thànhgroup-has-*/peer-has-*. data-[...]vàaria-[...]là nền tảng để style Radix & shadcn — primitive phát trạng thái ra attribute, Tailwind style theo ngay trong markup, không cần JS cho phần thị giác.- Variant chồng lên đọc phải→trái như một AND; giữ chuỗi ngắn.
before:/after:luôn cầncontent. Đừng ghép tên class bằng template string động — Tailwind quét text tĩnh.
Kết
Variant là concept đơn giản: cùng 1 utility class, thêm điều kiện kích hoạt. Nắm được pattern này, mọi prefix dù phức tạp đến đâu đều dễ đọc — chỉ cần đọc từ phải sang trái: utility gốc là gì, điều kiện kích hoạt là gì.
Thực tế khi code, 80% thời gian chỉ dùng hover:, focus-visible:, md:, dark:, disabled:, group-hover:, và data-[...]:. Phần còn lại — has-*, peer-*, structural, arbitrary — đã nằm trọn trong bài này để bạn tra khi cần, không phải mở docs nữa.