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.
So far our utilities applied unconditionally {Tới giờ utility của ta áp vô điều kiện}. Real UI is stateful — buttons darken on hover, inputs glow on focus, panels open and close {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}. Variants are how Tailwind expresses “apply this utility, but only in this state or context” {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”}. This part also unlocks the mechanism Radix and shadcn use — so don’t skip it {Phần này cũng mở khóa cơ chế mà Radix và shadcn dùng — đừng bỏ qua}.
1. The variant syntax {Cú pháp variant}
A variant is a prefix before a utility, separated by a colon {Một variant là tiền tố trước utility, ngăn bằng dấu hai chấm}:
<button class="bg-indigo-500 hover:bg-indigo-600">Save</button>
This reads: background is indigo-500 normally, and indigo-600 when hovered {Đọc là: nền indigo-500 bình thường, và indigo-600 khi hover}. Under the hood Tailwind generates .hover\:bg-indigo-600:hover { … } {Bên dưới Tailwind sinh ra .hover\:bg-indigo-600:hover { … }}.
2. Interactive & form states {Trạng thái tương tác & form}
The everyday set {Bộ dùng hằng ngày}:
| Variant | Fires when | Use for |
|---|---|---|
hover: | pointer over element | affordance |
focus: / focus-visible: | element focused (keyboard) | a11y rings |
active: | being pressed | tactile feedback |
disabled: | disabled attribute set | dim/lock controls |
checked: | checkbox/radio checked | toggles |
required: / invalid: | form validation state | inline errors |
<button
class="rounded-md bg-indigo-500 px-4 py-2 text-white
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>
Always include focus styles.
focus-visible:gives keyboard users a ring without showing it on mouse clicks — accessibility you get for one class {Luôn có focus style.focus-visible:cho người dùng bàn phím một vòng focus mà không hiện khi bấm chuột — a11y chỉ tốn một class}.
Try all four states on a real button (and toggle disabled) in the first panel below {Thử cả bốn trạng thái trên một nút thật (và bật disabled) ở panel đầu tiên bên dưới}:
3. group-* — react to a parent’s state {group-* — phản ứng theo trạng thái cha}
Sometimes a child should restyle when the parent is hovered/focused {Đôi khi một con cần đổi style khi cha được hover/focus}. Mark the parent group, then prefix the child with group-hover: {Đánh dấu cha là group, rồi gắn tiền tố group-hover: cho con}:
<a href="#" class="group block rounded-lg border p-4 hover:border-indigo-400">
<h3 class="font-semibold">
Read the docs
<span class="inline-block transition group-hover:translate-x-1 group-hover:text-indigo-400">→</span>
</h3>
</a>
Hover anywhere on the card and the arrow slides — the child reacts to the parent {Hover bất kỳ đâu trên card và mũi tên trượt — con phản ứng theo cha}. You can also name groups (group/item, group-hover/item:) when they nest {Bạn cũng có thể đặt tên group (group/item, group-hover/item:) khi chúng lồng nhau}.
4. peer-* — react to a sibling’s state {peer-* — phản ứng theo trạng thái anh em}
peer is group for siblings, typically a form control {peer là group cho anh em, thường là một control form}. Mark the input peer, then a later sibling reacts {Đánh dấu input là peer, rồi một anh em đứng sau phản ứng}:
<label class="flex items-center gap-2">
<input type="checkbox" class="peer" />
<span class="text-slate-400 peer-checked:text-emerald-500">I agree</span>
</label>
<!-- classic floating label / inline validation -->
<input class="peer ..." required />
<p class="hidden peer-invalid:block text-red-500 text-sm">Required</p>
The constraint to remember {Ràng buộc cần nhớ}: peer-* only styles elements that come after the peer in the DOM (it’s a CSS sibling selector) {peer-* chỉ style được phần tử đứng sau peer trong DOM (đó là bộ chọn anh em của CSS)}.
5. data-[state] — the Radix & shadcn key {data-[state] — chìa khóa cho Radix & shadcn}
This is the bridge to the rest of the series {Đây là cầu nối tới phần còn lại của series}. Radix primitives expose their internal state as data attributes — data-state="open", data-state="checked", data-disabled, data-side="top" {Các primitive của Radix phơi trạng thái nội bộ thành data attribute}. You style against them with the attribute variant {Bạn style dựa vào chúng bằng variant thuộc tính}:
<!-- A Radix accordion trigger styles itself by its own state -->
<button
data-state="open"
class="flex w-full justify-between
data-[state=open]:text-indigo-500"
>
Section
<svg class="transition-transform data-[state=open]:rotate-180">▾</svg>
</button>
This is why Tailwind + Radix is such a clean pairing {Đây là lý do Tailwind + Radix kết hợp sạch đến vậy}: Radix owns behavior and emits state; Tailwind styles each state declaratively, right in the markup {Radix lo hành vi và phát trạng thái; Tailwind style từng trạng thái khai báo ngay trong markup}. Toggle the accordion in the explorer’s last panel to watch data-state flip and drive the styles {Bật/tắt accordion ở panel cuối để xem data-state đổi và điều khiển style}.
6. Stacking variants & responsive combos {Chồng variant & kết hợp responsive}
Variants compose left-to-right and you can chain them {Variant kết hợp từ trái sang phải và bạn có thể nối chuỗi}:
<!-- only on md+ screens, only on hover, only in dark mode -->
<div class="md:dark:hover:bg-slate-800">…</div>
Order doesn’t change the meaning for independent conditions, but read them as an AND: “md AND dark AND hover” {Thứ tự không đổi nghĩa với các điều kiện độc lập, nhưng hãy đọc như một AND: “md VÀ dark VÀ hover”}. Keep stacks short — three is plenty; deeper usually means you want a component or a data-* state instead {Giữ chuỗi ngắn — ba là nhiều rồi; sâu hơn thường nghĩa là bạn nên dùng component hoặc trạng thái data-*}.
7. Exercises {Bài tập}
1. Build a button with: darker bg on hover, a visible focus ring for keyboard users, a slight press-down on active, and dimmed + unclickable when disabled {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}.
Solution {Lời giải}
<button class="rounded-md bg-indigo-500 px-4 py-2 text-white
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>2. A card where hovering the whole card reveals a “View →” link in accent color {Một card mà hover toàn card sẽ làm link “View →” hiện màu accent}.
Solution {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>
</a>3. An email input that shows a red “Invalid email” message only when its value is invalid — no JS {Một input email hiện thông báo đỏ “Invalid email” chỉ khi giá trị sai — không JS}.
Solution {Lời giải}
<input type="email" class="peer ..." required />
<p class="hidden peer-invalid:block text-sm text-red-500">Invalid email</p>Stretch {Nâng cao}: in the explorer’s accordion panel, note how both the trigger color and the chevron rotation are driven by the same data-state — write the two classes that do it {trong panel accordion, để ý cả màu trigger lẫn xoay chevron đều do cùng data-state điều khiển — viết hai class làm điều đó}.
Key takeaways {Điểm chính}
- A variant is a prefix that applies a utility conditionally (
hover:,focus-visible:,disabled:…) {Một variant là tiền tố áp utility có điều kiện}. group-*reacts to a parent’s state;peer-*reacts to a sibling’s state (sibling must come after) {group-*phản ứng theo trạng thái cha;peer-*theo trạng thái anh em (anh em phải đứng sau)}.data-[state=…]:styles elements by their data attributes — the foundation for styling Radix & shadcn {data-[state=…]:style theo data attribute — nền tảng để style Radix & shadcn}.- Variants stack as an AND; keep chains short {Variant chồng lên như một AND; giữ chuỗi ngắn}.
Next up {Tiếp theo}
Part 5 — Reusable components the right way: the moment classes repeat, you need components — and the cn() helper built on clsx + tailwind-merge, plus cva for type-safe variants. This is the professional pattern shadcn itself uses {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}.