jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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-500biế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 variantTiền tố ví dụBọc utility thành
Pseudo-class / selectorhover:, 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ậy hover:bg-indigo-600 mớ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ểu hover:{...}. 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)

VariantSelector sinh raKích hoạt khiDùng cho
hover::hovercon 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-visiblefocus qua bàn phím (ẩn khi click chuột)ring a11y “đúng lúc”
focus-within::focus-withinchính nó hoặc con đang focuslàm nổi cả khối form
active::activeđang nhấn giữphản hồi xúc giác (scale nhẹ)
visited::visitedlink đã ghé (chỉ vài thuộc tính màu)màu link đã đọc
target::targetid phần tử khớp hash URLhighlight mục được nhảy tới

3.2 Variant trạng thái form

VariantSelectorKích hoạt khiDùng cho
disabled::disabledcó thuộc tính disabledlàm mờ + khóa control
enabled::enabledcontrol bật (mặc định)đảo ngược của disabled
checked::checkedcheckbox/radio được tíchtoggle, custom checkbox
indeterminate::indeterminatecheckbox “lửng""chọn tất cả” một phần
default::defaultoption/radio mặc định của formđánh dấu lựa chọn gốc
required::requiredinput có requireddấu sao bắt buộc
optional::optionalinput không requirednhãn “tùy chọn”
valid::validgiá trị hợp lệ theo constraintviền xanh
invalid::invalidgiá trị sai constraintviền đỏ, báo lỗi
user-valid::user-validhợp lệ sau khi người dùng tương táclỗi không nhảy ra sớm
user-invalid::user-invalidsai sau khi đã chạm vàoUX báo lỗi lịch sự hơn
in-range::in-rangesố trong minmaxslider/number hợp lệ
out-of-range::out-of-rangesố ngoài khoảngcảnh báo vượt giới hạn
placeholder-shown::placeholder-showninput đang trốngfloating label (mục 19)
autofill::autofilltrình duyệt tự điềnsửa nền vàng autofill
read-only::read-onlyreadonlystyle 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òng focus-visible:outline-2 là 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ống required đã “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:

VariantSelectorChọn
first::first-childcon đầu tiên
last::last-childcon cuối cùng
only::only-childcon 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-typecuối cùng cùng loại tag
only-of-type::only-of-typeduy nhất cùng loại tag
empty::emptyphầ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…

VariantPseudo-elementDùng cho
before:::beforechèn nội dung trước (icon, dấu)
after:::afterchèn nội dung sau (badge, gạch)
placeholder:::placeholdermàu/kiểu chữ placeholder
selection:::selectionmàu nền/chữ khi bôi đen text
marker:::markerdấu đầu dòng của <li>
first-line:::first-linedòng đầu của đoạn
first-letter:::first-letterchữ cái đầu (drop cap)
file:::file-selector-buttonnút “Choose file” của input file
backdrop:::backdropnề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/::after cần content. Hai pseudo-element này không hiện nếu thiếu thuộc tính content, dù bạn đã set kích thước/màu. Luôn kèm before:content-[''] (chuỗi rỗng cho khối trang trí thuần) hoặc after:content-['*'] (nội dung thật). Đây là lý do before:block before:h-2 before:w-2 before:bg-red-500 im lặng không vẽ gì — thiếu before: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.

PrefixMin-widthTương ứng
(không)0pxMobile
sm:640pxTablet nhỏ
md:768pxTablet
lg:1024pxLaptop
xl:1280pxDesktop
2xl:1536pxDesktop 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ápNghĩa
md:flextừ md trở lên
max-md:flexdưới md (đến hết)
md:max-lg:flextừ md đến dưới lg (một khoảng)
min-[900px]:grid-cols-2từ đúng 900px trở lên (giá trị tùy ý)
max-[1100px]:hiddendướ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-variant là 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ùng sidebar-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

peergroup 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ằng order-* 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ấuPhần tử được styleHướngSelector CSS
group-*tổ tiên (.group)hậu duệcha → con.group:STATE &
peer-*anh em trước (.peer)anh em sautrướ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 attributeARIA 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-stateopen / closedAccordion, Dialog, Popover, Dropdown, Collapsible
data-statechecked / unchecked / indeterminateCheckbox, Switch, Radio
data-stateactive / inactiveTabs
data-stateon / offToggle
data-statevisible / hiddenTooltip
data-disabled(chỉ có mặt)hầu hết control
data-highlighted(chỉ có mặt)item menu/select đang được trỏ
data-sidetop/right/bottom/leftnội dung Popover/Tooltip
data-orientationhorizontal/verticalTabs, Separator, Slider
aria-expandedtrue/falsetrigger Dropdown/Combobox
aria-selectedtrue/falseitem 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 useState cho 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 trong className.


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ápSinh raNghĩa
[&>li]:mb-2& > limọi <li> con trực tiếp
[&_p]:leading-relaxed& pmọ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-scrollbarpseudo-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]& > 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ồng has-*), thứ tự ý 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.

<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ứngNguyên nhânCách xử lý
Hover chỉ đổi một thuộc tínhQuên lặp tiền tố cho từng propertyhover:bg-x hover:text-y hover:shadow-z — mỗi cái một tiền tố
peer-* không phản ứngPhầ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 contentLuôn kèm before:content-['']
data-[state=open]: không ănSai cú pháp ngoặc / sai tên attributeKiể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 groupCó group lồng nhauĐặt tên: group/x + group-hover/x:
empty:hidden không ẩnBê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 conNhầ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 sinhTailwind quét text tĩnh, không chạy JSViế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 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ành group-has-* / peer-has-*.
  • data-[...]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ần content. Đừ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.