jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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ệnbg-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-[...]aria-[...] chính 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ừ đâuLợi ích
Accessibilityaria-*, disabled, rolescreen reader và keyboard hiểu đúng
Stylingaria-*, data-*, pseudo-classTailwind variant style trực tiếp
Testing/debugDevTools và test querynhì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)

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::focusphần tử được focus (chuột hoặc bàn phím)ring/outline
focus-visible::focus-visiblefocus qua bàn phím (không hiệ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 được 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

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 ở trạng thái “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 quá 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ống (placeholder đang hiện)floating label (mục 12)
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” 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ò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ý. Khi trình duyệt mục tiêu chưa hỗ trợ, dùng cặp peer-[: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:

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 ý (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

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.


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-*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ấ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)

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 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 trong menu/select đang được trỏ
data-sidetop/right/bottom/leftnội dung Popover/Tooltip (theo hướng mở)
data-orientationhorizontal/verticalTabs, Separator, Slider
aria-expandedtrue/falsetrigger Dropdown/Combobox
aria-selectedtrue/falseitem 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 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. Bật/tắt accordion ở panel cuối của demo để thấy data-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.

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 của input
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 đỏ 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/::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-[''].


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ồ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 (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á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 ý
<!-- 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]& > 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.

<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ứ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: (hoặc fallback) 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 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 conNhầ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 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à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 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/::after bắ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.