Tailwind, Radix & shadcn/ui · Part 4 — Variants, States & Composition
Style on interaction and context: hover/focus/active/disabled, the group-* and peer-* patterns, and data-[state] styling — the exact mechanism Radix and shadcn rely on. With a live state explorer.
Tới giờ utility của ta áp vô điều kiện — bg-indigo-500 thì lúc nào nền cũng tím. Nhưng UI thật thì có trạng thái: nút tối đi khi hover, input sáng lên khi focus, panel mở/đóng, checkbox tích thì label đổi màu. Variant là cách Tailwind diễn đạt “áp utility này, nhưng chỉ trong trạng thái hoặc ngữ cảnh này” — mà vẫn ở nguyên trong markup, không phải mở stylesheet hay viết JS.
Phần này là một trong những bài quan trọng nhất series, vì hai lý do. Thứ nhất, variant là thứ nâng utility từ “inline style trá hình” lên thành một hệ thống đầy đủ. Thứ hai, các variant data-[...] và aria-[...] chính là cơ chế mà Radix phơi trạng thái và shadcn/ui style theo — nắm chắc phần này, bạn sẽ đọc hiểu được mọi component shadcn ở các phần sau. Ta đi từ cú pháp, qua toàn bộ họ variant, tới cơ chế bên dưới và công thức thực chiến.
Nguyên tắc production: state nên sống trên DOM
Người mới thường dùng JS state để bật/tắt class thủ công:
<button className={open ? 'text-indigo-600 rotate-180' : 'text-slate-700'}>
Toggle
</button>
Cách này chạy được, nhưng dễ tách rời UI khỏi accessibility: element có thể “trông mở” nhưng aria-expanded vẫn sai, hoặc dialog “trông disabled” nhưng vẫn focus được. Cách bền hơn là để trạng thái quan trọng hiện diện trên DOM bằng attribute có nghĩa:
<button
aria-expanded="true"
data-state="open"
class="text-slate-700 aria-expanded:text-indigo-600 data-[state=open]:font-semibold"
>
Toggle
</button>
Khi state nằm trên DOM, ba tầng cùng đọc một nguồn:
| Tầng | Đọc state từ đâu | Lợi ích |
|---|---|---|
| Accessibility | aria-*, disabled, role | screen reader và keyboard hiểu đúng |
| Styling | aria-*, data-*, pseudo-class | Tailwind variant style trực tiếp |
| Testing/debug | DevTools và test query | nhìn DOM là biết state thật |
Đây là lý do Radix và shadcn dựa mạnh vào data-state, data-disabled, aria-expanded, aria-selected: state không bị giấu trong component internals, mà được phơi ra để CSS, a11y và test cùng dùng.
1. Variant là gì — giải phẫu cú pháp
Một variant là tiền tố đặt trước utility, ngăn bằng dấu hai chấm:
hover:bg-indigo-600
─┬─── ──────┬──────
│ └── utility (áp khi điều kiện đúng)
└── variant (điều kiện)
<button class="bg-indigo-500 hover:bg-indigo-600">Save</button>
Đọc là: nền indigo-500 bình thường, và indigo-600 khi hover. Điều quan trọng cần hiểu — variant không “ghi đè” gì cả; nó sinh ra một rule CSS riêng có điều kiện chọn lọc cao hơn. Mở DevTools sẽ thấy đúng hai rule:
.bg-indigo-500 { background-color: #6366f1 } /* luôn áp */
.hover\:bg-indigo-600:hover { background-color: #4f46e5 } /* chỉ khi :hover */
Vài chi tiết về cơ chế giúp bạn khỏi bối rối về sau:
- Dấu
:trong tên class được escape thành\:trong CSS sinh ra (.hover\:bg-indigo-600). Đó là lý do tên class hợp lệ —:là ký tự đặc biệt nên phải thoát. - Variant là một map: mỗi tiền tố ánh xạ sang một selector (
hover:→&:hover), một at-rule (md:→@media (min-width: 48rem)), hoặc một bộ chọn quan hệ (group-hover:→.group:hover &). - Mọi variant đều có thể đứng trước bất kỳ utility nào.
hover:không chỉ cho màu —hover:scale-105,hover:shadow-lg,focus:w-64đều hợp lệ. Đây là điểm khác cốt lõi với CSS framework cũ vốn chỉ có sẵn vài state cố định.
Quy tắc vàng cần thuộc ngay: 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ách “gom nhóm” nhưhover:{...}. Quên lặp tiền tố là lỗi #1 của người mới.
2. Trạng thái tương tác & form — bảng tra đầy đủ
Đây là họ variant gặp hằng ngày: pseudo-class phản ánh tương tác chuột/bàn phím và trạng thái form. Bảng dưới là bộ đủ dùng cho 95% UI.
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 | phần tử được focus (chuột hoặc bàn phím) | ring/outline |
focus-visible: | :focus-visible | focus qua bàn phím (không hiệ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 được 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 |
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 ở trạng thái “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 quá 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 (placeholder đang hiện) | floating label (mục 12) |
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” trông như sau — để ý mỗi state là một (hoặc vài) tiền tố, và 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:opacity-40 disabled:pointer-events-none"
>
Save changes
</button>
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ý. Khi trình duyệt mục tiêu chưa hỗ trợ, dùng cặppeer-[:user-invalid]hoặc fallback JS.
Thử trực tiếp cả bốn trạng thái trên một nút thật (và bật disabled) ở panel đầu tiên, rồi nghịch tiếp accordion data-state ở panel cuối:
3. focus vs focus-visible vs focus-within — phân biệt cho rõ
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>
4. 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 ý (arbitrary) |
nth-last-[2]: | :nth-last-child(2) | đếm từ cuối |
Ví dụ kinh điển: bảng có viền giữa các hàng nhưng không viền dưới hàng cuối, và nền xen kẽ:
<ul class="divide-y divide-slate-200">
<li class="p-3 odd:bg-slate-50 last:rounded-b-lg first:rounded-t-lg">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="empty:hidden mt-1 text-sm text-red-500"></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' '.
5. 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> {/* chỉ theo nút */}
</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.
6. 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.
7. 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.
<!-- 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>
has-* ghép được với group/peer: group-has-* và peer-has-* 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>
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) |
8. 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 trong menu/select đang được trỏ |
data-side | top/right/bottom/left | nội dung Popover/Tooltip (theo hướng mở) |
data-orientation | horizontal/vertical | Tabs, Separator, Slider |
aria-expanded | true/false | trigger Dropdown/Combobox |
aria-selected | true/false | item Select/Tabs |
<!-- ví dụ 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-[highlighted]:bg-slate-100
data-[disabled]:opacity-50 data-[disabled]:pointer-events-none"
>
Profile
</div>
<!-- nội dung popover trượt vào theo hướng nó mở (data-side) -->
<div class="data-[side=top]:slide-in-from-bottom-2
data-[side=bottom]:slide-in-from-top-2">…</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. Bật/tắt accordion ở panel cuối của demo để thấydata-stateđổi và kéo theo style.
Checklist khi style state của component
Trước khi viết state class cho một component tương tác, tự hỏi:
| Câu hỏi | Ưu tiên |
|---|---|
| Đây là state native của HTML không? | Dùng pseudo-class: disabled:, checked:, invalid:, focus-visible: |
| Đây là state accessibility đã có ARIA không? | Dùng aria-*: aria-expanded:, aria-selected:, aria-invalid: |
| Đây là state nội bộ của primitive Radix không? | Dùng data-*: data-[state=open]:, data-[highlighted]: |
| Con cần phản ứng theo cha? | group-* hoặc named group |
| Cha cần phản ứng theo con? | has-* |
| Anh em sau cần phản ứng theo control trước? | peer-*, nhớ ràng buộc thứ tự DOM |
| Điều kiện quá dài/lặp nhiều nơi? | Tạo @custom-variant ở Phần 6 |
Quy tắc nhỏ: native/ARIA trước, data-* cho state thư viện, JS class toggle là lựa chọn cuối. JS vẫn cần để thay đổi state, nhưng không nên là nơi duy nhất mô tả state cho CSS và a11y.
9. Pseudo-element — before, after, placeholder, selection, marker
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ử). Đây là cách tạo nội dung trang trí, custom marker, hay đổi màu vùng bôi đen.
| 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 của input |
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 đỏ bắt buộc, tạo bằng ::after -->
<label class="after:ml-0.5 after:text-red-500 after:content-['*']">Email</label>
<!-- placeholder nhạt, vùng bôi đen tím -->
<input class="placeholder:text-slate-400 selection:bg-indigo-200" 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-[''].
10. Chồng variant — đọc từ phải sang trái
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À (trong dark mode) VÀ (màn hình 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 các đ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.
- Khi chồng variant cùng họ mà 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 (Phần 5) hoặc dời logic sang một
data-*duy nhất do JS điều khiển, thay vì nhồi điều kiện vào class.
11. Arbitrary variant — [&>li], [&_p], [@supports...]
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 ý |
<!-- style con mà không cần đụng vào từng con (hữu ích cho nội dung do CMS sinh) -->
<article class="[&>h2]:mt-8 [&>h2]:text-xl [&_a]:text-indigo-600 [&_a]:underline">
{/* 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="overflow-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">…</div>
<!-- chỉ áp blur khi trình duyệt hỗ trợ -->
<header class="bg-white/80 [@supports(backdrop-filter:blur(0))]:backdrop-blur-md">…</header>
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 dấu 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 và mất tính nhất quán. 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 (Phần 7) thay vì rải
[&>...]khắp nơi.
12. Công thức thực chiến
Floating label (không JS)
Mẹo: 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 left-3 top-2 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>
Khi ô trống (placeholder-shown), label tụt xuống to như placeholder; khi focus hoặc đã có chữ, label bay lên nhỏ lại — toàn bộ bằng CSS.
Toggle lỗi không JS
<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 đã chạm vào ô — không nhảy đỏ ngay lúc load.
Custom checkbox / radio “card”
<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 — nhờ has-[:checked]:.
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 đề (group-data-[state=open]:text-indigo-600) vừa xoay chevron (group-data-[state=open]:rotate-180). 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>
13. 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: (hoặc fallback) 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 variant 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 |
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.
14. 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:opacity-40 disabled:pointer-events-none">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="[&>li]:mb-2 [&_a]:text-indigo-600 [&_a]:underline">…</ul>[&>li] = con trực tiếp (dùng >); [&_a] = hậu duệ bất kỳ (dùng _ = space).
Nâng cao: mở demo, mở/đóng accordion ở panel cuối và xác nhận cả màu trigger lẫn góc xoay chevron đều bám theo một data-state duy nhất.
Đ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 á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ọ variant trạng thái (
hover,focus-visible,disabled,checked,user-invalid,placeholder-shown…) lẫn cấu trúc (first,last,odd,empty,nth-[...]). - 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 từng trạng thái ngay trong markup, không cần JS cho phần thị giác.- Pseudo-element (
before,after,placeholder,selection,marker) mở rộng tầm với; nhớ::before/::afterbắt buộc cócontent. - Variant chồng lên đọc phải→trái như một AND; giữ chuỗi ngắn. Khi bí, arbitrary variant
[&>li],[&_p],[@supports...]là cửa thoát hiểm — dùng tiết chế.
Tiếp theo
Phần 5 — Component tái sử dụng đúng cách: khi class lặp lại, bạn cần component — cùng helper cn() dựng trên clsx + tailwind-merge, và cva cho variant type-safe. Đây là mẫu chuyên nghiệp mà chính shadcn dùng.