Tailwind CSS Variants — Hiểu một lần, dùng mãi
Tổng hợp toàn bộ cơ chế variant trong Tailwind CSS v4: từ hover, focus, responsive, dark mode đến group, peer, has, arbitrary variant. Giải thích tại sao gọi là variant và cách tư duy đúng khi dùng.
Tailwind CSS Variants — Hiểu một lần, dùng mãi
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.
Bài viết này sẽ đi từ gốc lên ngọn: variant là gì, tại sao gọi như vậy, và toàn bộ cách dùng trong thực tế.
”Variant” nghĩa là gì?
Từ “variant” nghĩa là biến thể. Xét class bg-sky-500 — đây là bản gốc, luôn áp dụng:
.bg-sky-500 {
background-color: var(--color-sky-500);
}
Còn hover:bg-sky-500 là biến thể của nó — cùng style, nhưng chỉ kích hoạt khi có điều kiện:
.hover\:bg-sky-500:hover {
background-color: var(--color-sky-500);
}
Hai class cùng output CSS, chỉ khác điều kiện áp dụng. Phần hover: chính là variant — biến thể có điều kiện.
Điểm quan trọng: variant không chỉ là state. Tất cả các prefix tạo điều kiện đều là variant:
hover:bg-sky-500 → biến thể "khi hover"
md:bg-sky-500 → biến thể "khi màn hình ≥ 768px"
dark:bg-sky-500 → biến thể "khi dark mode"
first:bg-sky-500 → biến thể "khi là first-child"
disabled:bg-sky-500 → biến thể "khi disabled"
Cùng 1 utility, khác điều kiện kích hoạt → variant. Nếu gọi hover: là “pseudo-class prefix” thì md: gọi là gì? dark: gọi là gì? Từ “variant” bao trùm hết tất cả.
Nhóm 1: Pseudo-class variants
Đây là nhóm dùng nhiều nhất — style element dựa trên trạng thái tương tác.
Hover, focus, active
<button class="bg-blue-500 hover:bg-blue-700 focus:outline-2 active:bg-blue-800">
Save changes
</button>
Mỗi state một class riêng biệt. 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à điểm khác biệt lớn so với CSS truyền thống (1 class chứa cả default lẫn hover).
Disabled, required, invalid
<input
class="border-gray-300 disabled:opacity-50 disabled:pointer-events-none
invalid:border-pink-500 required:border-red-500"
/>
Với form, các variant này cực kỳ tiện — style phản ứng theo validation state mà không cần logic JS.
First, last, odd, even
<ul>
{items.map(item => (
<li class="py-4 first:pt-0 last:pb-0 odd:bg-white even:bg-gray-50">
{item.name}
</li>
))}
</ul>
focus-visible vs focus
Một chi tiết nhỏ nhưng quan trọng cho accessibility:
<button class="focus-visible:ring-2 focus-visible:ring-blue-500">
Click me
</button>
focus: kích hoạt khi click chuột lẫn tab keyboard. focus-visible: chỉ kích hoạt khi navigate bằng keyboard — tránh hiện ring xấu khi user click chuột.
Bảng tham chiếu nhanh
| Variant | CSS | Khi nào dùng |
|---|---|---|
hover: | :hover | Mouse hover |
focus: | :focus | Click hoặc tab vào |
focus-visible: | :focus-visible | Chỉ keyboard focus |
focus-within: | :focus-within | Con bên trong đang focus |
active: | :active | Đang nhấn giữ |
disabled: | :disabled | Element bị disabled |
invalid: | :invalid | Input không hợp lệ |
checked: | :checked | Checkbox/radio được chọn |
required: | :required | Input bắt buộc |
first: | :first-child | Phần tử đầu tiên |
last: | :last-child | Phần tử cuối cùng |
odd: | :nth-child(odd) | Vị trí lẻ |
even: | :nth-child(even) | Vị trí chẵn |
visited: | :visited | Link đã truy cập |
Nhóm 2: Pseudo-element variants
Style cho các pseudo-element CSS.
<!-- Dấu * đỏ trước label bắt buộc -->
<label class="before:content-['*'] before:text-red-500 before:mr-1">
Email
</label>
<!-- Placeholder mờ -->
<input class="placeholder:text-gray-400 placeholder:italic" placeholder="Enter email" />
<!-- Highlight text khi bôi đen -->
<p class="selection:bg-pink-200 selection:text-pink-900">
Bôi đen đoạn text này thử xem
</p>
<!-- Bullet color cho list -->
<li class="marker:text-blue-500">Item</li>
| Variant | CSS | Ví dụ |
|---|---|---|
before: | ::before | before:content-['→'] |
after: | ::after | after:content-[''] |
placeholder: | ::placeholder | placeholder:text-gray-400 |
selection: | ::selection | selection:bg-yellow-200 |
marker: | ::marker | marker:text-blue-500 |
first-line: | ::first-line | first-line:font-bold |
Nhóm 3: Responsive variants
Tailwind dùng mobile-first breakpoints: class không prefix = mọi kích thước. Prefix áp dụng từ breakpoint đó trở lên.
| Prefix | Min-width | Tương ứng |
|---|---|---|
| (không) | 0px | Mobile |
sm: | 640px | Tablet nhỏ |
md: | 768px | Tablet |
lg: | 1024px | Laptop |
xl: | 1280px | Desktop |
2xl: | 1536px | Desktop lớn |
<!-- Dọc trên mobile → ngang từ md trở lên -->
<div class="flex flex-col md:flex-row">
<!-- 1 cột → 2 cột → 3 cột -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Ẩn trên mobile, hiện từ md -->
<nav class="hidden md:block">
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
<!-- Chỉ áp dụng từ md đến dưới lg -->
<div class="md:max-lg:flex">
<!-- Breakpoint tùy ý -->
<div class="min-[900px]:grid-cols-2">
Nhóm 4: Dark mode variant
<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 prefers-color-scheme media query — theo setting OS. Muốn toggle thủ công bằng JS (thêm class .dark trên <html>), cấu hình:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Nhóm 5: group và peer — Quan hệ cha-con, anh-em
Đây là nơi variant bắt đầu trở nên thú vị.
group — Style con theo state của cha
Thêm class group lên parent, rồi dùng group-{variant}: ở children:
<a href="#" class="group rounded-lg p-6">
<h3 class="group-hover:text-blue-600">Card title</h3>
<p class="group-hover:text-gray-800">Description</p>
<span class="group-hover:underline">Read more →</span>
</a>
Khi hover lên thẻ <a> (parent), tất cả children có group-hover: đồng loạt thay đổi.
Đặt tên group khi lồng nhau
Khi có nhiều group lồng nhau, đặt tên để chỉ định rõ:
<div class="group/card">
<div class="group/header">
<h3 class="group-hover/card:text-blue-600">Card hover → xanh</h3>
<span class="group-hover/header:underline">Header hover → underline</span>
</div>
</div>
peer — Style theo state của anh em
Tương tự group, nhưng cho sibling (cùng cấp):
<input type="checkbox" class="peer" />
<label class="peer-checked:text-blue-600 peer-checked:font-bold">
Option A
</label>
Khi checkbox được chọn → label bên cạnh đổi style. Lưu ý: peer phải đặt trước element cần style trong DOM.
Nhóm 6: has-* variant — Kiểm tra con cháu
CSS :has() selector cho phép style element dựa trên nội dung bên trong nó. Tailwind biến nó thành variant.
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)
Ứng dụng thực tế: Radio group
<label class="has-checked:bg-indigo-50 has-checked:text-indigo-900
has-checked:ring-indigo-200 border rounded-lg p-4 cursor-pointer">
<input type="radio" name="pay" class="checked:border-indigo-500" />
Google Pay
</label>
Label tự đổi style khi radio bên trong được chọn — không cần JS.
Kết hợp group-has / peer-has
<!-- Icon mũi tên chỉ hiện khi card cha có chứa link -->
<div class="group">
<p>Some text with <a href="#">a link</a></p>
<svg class="hidden group-has-[a]:block">→</svg>
</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?"
Nhóm 7: not-* variant — Phủ định
Style khi điều kiện không đúng:
<!-- Hover effect chỉ khi KHÔNG focus -->
<button class="hover:not-focus:bg-indigo-700">Save</button>
<!-- Style khi browser KHÔNG hỗ trợ grid -->
<div class="not-supports-[display:grid]:flex">Fallback layout</div>
Nhóm 8: Arbitrary variants — Selector tùy ý
Khi Tailwind không có sẵn variant cần thiết, tự viết selector bất kỳ bọc trong [...]. Ký tự & đại diện cho element hiện tại.
Style children
<!-- Tất cả <li> con trực tiếp -->
<ul class="[&>li]:rounded-lg [&>li]:p-4">
<!-- Tất cả <p> lồng sâu bên trong -->
<div class="[&_p]:mb-4 [&_p]:text-gray-600">
Phân tích [&>li]:rounded-lg:
& = chính thẻ <ul>
> = con trực tiếp
li = target
→ CSS: ul > li { border-radius: var(--radius-lg) }
Data attribute selector
<!-- Đầy đủ -->
<button class="[&[data-state=open]]:bg-blue-500">
<!-- Tailwind shorthand -->
<button class="data-[state=open]:bg-blue-500">
Hai dòng trên tương đương nhau. Tailwind cung cấp data-[...] shorthand cho tiện.
Sibling 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>
@supports, @media tùy ý
<div class="[@supports(display:grid)]:grid">
<div class="[@media(orientation:landscape)]:flex-row">
Stack variants — Xếp chồng nhiều điều kiện
Variant có thể kết hợp không giới hạn:
<!-- Dark mode + màn hình lớn + hover -->
<button class="dark:lg:hover:bg-fuchsia-600">
<!-- Disabled + hover → giữ nguyên, không đổi màu -->
<button class="bg-blue-500 hover:bg-blue-700 disabled:hover:bg-blue-500">
<!-- Group hover + dark mode -->
<div class="group">
<p class="dark:group-hover:text-white">Adaptive text</p>
</div>
Đọc từ phải sang: dark:lg:hover:bg-fuchsia-600 = “áp dụng bg-fuchsia-600 khi hover VÀ screen ≥ lg VÀ dark mode”.
Áp dụng 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 prefix cho mỗi utility class.
<button class="data-[state=open]:bg-blue-500
data-[state=open]:text-white
data-[state=open]:rotate-180">
Tailwind không có cú pháp gom kiểu data-[state=open]:{bg-blue-500 text-white}. Mỗi utility một class.
Khi quá dài (5-6+ property), chuyển sang custom CSS:
/* index.css */
@layer components {
.accordion-trigger {
@apply px-4 py-2 transition-all;
&[data-state="open"] {
@apply bg-blue-500 text-white rotate-180 rounded-lg font-semibold;
}
}
}
Hoặc viết mỗi class một dòng trong JSX cho dễ đọc:
<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",
)}
/>
Sơ đồ quyết định: Chọn variant nào?
Muốn style theo state tương tác?
├── Hover/focus/active → hover: / focus: / active:
├── Form state → disabled: / invalid: / checked: / required:
├── Vị trí trong list → first: / last: / odd: / even:
└── Phủ định → not-hover: / not-focus: / ...
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:
└── Breakpoint tùy ý → min-[900px]:
Muốn style theo quan hệ element?
├── Theo state cha → group + group-hover: / group-focus:
├── 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:
Selector phức tạp, không có variant sẵn?
├── Target children → [&>li]: / [&_p]:
├── Data attribute → data-[state=open]:
├── Sibling combinator → [&+div]: / [&~span]:
└── @supports / @media → [@supports(...)]: / [@media(...)]:
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:, md:, dark:, disabled:, group-hover:, và data-[...]:. Phần còn lại tra cứu khi cần.