jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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-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ả.


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

VariantCSSKhi nào dùng
hover::hoverMouse hover
focus::focusClick hoặc tab vào
focus-visible::focus-visibleChỉ keyboard focus
focus-within::focus-withinCon bên trong đang focus
active::activeĐang nhấn giữ
disabled::disabledElement bị disabled
invalid::invalidInput không hợp lệ
checked::checkedCheckbox/radio được chọn
required::requiredInput bắt buộc
first::first-childPhần tử đầu tiên
last::last-childPhần tử cuối cùng
odd::nth-child(odd)Vị trí lẻ
even::nth-child(even)Vị trí chẵn
visited::visitedLink đã 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>
VariantCSSVí dụ
before:::beforebefore:content-['→']
after:::afterafter:content-['']
placeholder:::placeholderplaceholder:text-gray-400
selection:::selectionselection:bg-yellow-200
marker:::markermarker:text-blue-500
first-line:::first-linefirst-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.

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">

<!-- 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ả childrengroup-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 ≥ lgdark 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.