TailwindCSS 狀態變體:hover、group、peer 與 v4 has-* 全解 | TailwindCSS 完整教學
上一篇我們讓版面「隨螢幕大小伸縮」,但一個真正好用的介面還有另一半靈魂:同一個按鈕,滑鼠移上去要變色、按下去要凹陷、鍵盤 focus 時要有外框、被禁用時要變灰。這一篇 TailwindCSS 的狀態變體(State Variants),要用
hover:、focus:、active:、disabled:這組互動與表單前綴,加上group-*(父子連動)、peer-*(兄弟連動),以及 v4 新增的has-*(父依子)、not-*、in-*與aria-*/data-*屬性變體,讓介面隨著使用者的每一個動作活起來。
前言
上一篇 《響應式設計》 我們把版面從「打磨一個畫面」擴大到「一份程式碼適配所有螢幕」,用 sm:~2xl: 斷點與 v4 容器查詢,讓排版懂得看空間大小自我調整。但你可能也注意到:那些範例裡的按鈕、輸入框、連結,都只是「靜止」的樣子。真實介面不會靜止——使用者的滑鼠、鍵盤、觸控、表單輸入,每一個動作都應該得到視覺回饋。這就是狀態變體(State Variants) 要解決的核心問題:如何不寫一行額外 CSS、不碰 JavaScript,只用 class 前綴,就讓元素「隨互動狀態自動換樣式」?
先用一個生活化的類比建立心智模型。如果說響應式是「會看房間大小伸縮的家具」,那狀態變體就是「會回應你動作的智慧家電」:燈在你靠近時亮起(hover)、按下開關時有段落感(active)、故障時亮紅燈警示(invalid)、沒插電時整台變灰不能按(disabled)。你不需要為每種狀態各買一台家電(各寫一份樣式),而是給同一台裝上「懂得看你在做什麼、自動切換模式」的感應能力。Tailwind 的做法極其直接:在任何 utility 前面加一個「狀態前綴」,例如把 bg-blue-600 寫成 hover:bg-blue-700,這條樣式就「只在 hover 時生效」。
本系列以 TailwindCSS v4 為預設版本。狀態變體這組能力在 v4 有幾個關鍵升級:新增了 has-*(讓父元素依子元素狀態反應)、not-*(反向條件)、in-*(免標記的隱含 group)、data-* 的 bare value 語法,以及 group-has-*/peer-has-* 等組合變體。本篇你將學到:
- 互動與表單狀態:
hover:、focus:、active:、focus-visible:、focus-within:、disabled:、checked:、required:、invalid:等最常用的前綴 - 連動變體:
group-*(父子)與peer-*(兄弟)的運作機制、DOM 順序限制、具名語法 - v4 新變體:
has-*(父依子)、not-*(反向)、in-*(隱含 group)、group-has-*/peer-has-*組合 - 屬性變體與堆疊順序:
aria-*/data-*依屬性套用樣式、多個變體堆疊時的正確順序
本系列以 TailwindCSS v4 為預設版本,範例皆可直接執行。凡涉及 v3 差異之處,我會特別標註。
核心概念
狀態變體分類對照表
狀態變體看似五花八門,但骨架其實可以歸成四大類:互動狀態(隨滑鼠/鍵盤觸發)、表單狀態(隨欄位驗證/勾選變化)、連動變體(依父/兄弟元素反應)、屬性變體(依 ARIA/data 屬性)。下表先建立全景:
| 群組 | 代表前綴 | 觸發條件 | 對應 CSS | 典型用途 |
|---|---|---|---|---|
| 互動狀態 | hover: focus: active: | 滑鼠懸停/焦點/按壓 | :hover :focus :active | 按鈕、連結回饋 |
| 鍵盤焦點 | focus-visible: focus-within: | 鍵盤焦點/內部有焦點 | :focus-visible :focus-within | 無障礙外框、搜尋框 |
| 表單驗證 | required: invalid: disabled: | 必填/無效/禁用 | :required :invalid :disabled | 表單即時驗證 |
| 表單勾選 | checked: indeterminate: | 勾選/不確定 | :checked :indeterminate | 開關、核取方塊 |
| 父子連動 | group-*(需標記 group) | 父元素進入某狀態 | .group:hover .child | 卡片、選單 |
| 兄弟連動 | peer-*(需標記 peer) | 前方兄弟進入某狀態 | .peer:checked ~ .el | 表單提示、手風琴 |
| 父依子(v4) | has-* group-has-* peer-has-* | 內部含符合條件的子元素 | :has(…) | 選中高亮、容器發光 |
| 反向(v4) | not-* | 不符合某條件時 | :not(…) | 排除特定狀態 |
| 隱含 group(v4) | in-* | 任一祖先進入某狀態 | :where(…) & | 免標記的父子連動 |
| 屬性變體 | aria-* data-* | ARIA/data 屬性符合 | [aria-…] [data-…] | 元件狀態、排序箭頭 |
其中最常用的互動與表單狀態,建議先記熟這幾個:
| 前綴 | CSS 對應 | 說明 |
|---|---|---|
hover: | :hover | 滑鼠懸停 |
focus: | :focus | 獲得焦點(滑鼠或鍵盤皆會觸發) |
focus-visible: | :focus-visible | 僅鍵盤焦點才顯示,滑鼠點擊不觸發 |
focus-within: | :focus-within | 內部任一元素獲得焦點 |
active: | :active | 點擊/按壓中 |
visited: | :visited | 已訪問過的連結 |
disabled: | :disabled | 禁用狀態 |
checked: | :checked | 勾選狀態(checkbox/radio) |
required: | :required | 必填欄位 |
invalid: | :invalid | 輸入不符合驗證規則 |
運作原理:變體只是「加了條件的選擇器」
理解狀態變體,只要抓住一個關鍵觀念:每個變體前綴,都只是幫這條 utility「加上一個生效條件」,本質上就是生成一個帶 pseudo-class 或屬性選擇器的 CSS 規則。 hover:bg-blue-700 生成的其實就是 .hover\:bg-blue-700:hover { background: … }——一條「只在 :hover 時套用背景色」的普通 CSS。你在 HTML 裡疊了幾個狀態前綴,Tailwind 就替你生成幾條對應的條件式 CSS,完全不需要你手寫選擇器。
這個「加條件」的心智模型,套到連動變體上也一樣成立,只是條件從「元素自己」變成「相關元素」:
group-hover:的條件是「祖先中標了group的那個元素被 hover」,生成的選擇器類似.group:hover .group-hover\:text-blue-600。所以你必須「兩邊都動手」:父層標group、子層寫group-hover:。peer-invalid:的條件是「前方標了peer的兄弟元素處於:invalid」,生成的選擇器類似.peer:invalid ~ .peer-invalid\:block。因為用的是 CSS 的後續兄弟選擇器~,peer元素必須在 DOM 中排在前面。has-[:checked]:的條件是「這個元素內部有處於:checked的後代」,生成的選擇器類似.has-\[\:checked\]\:bg-blue-50:has(:checked)。方向是「父看子」,完全不需要在子層做任何標記。
理解了「變體=條件式選擇器」,你就能自己推導出任何組合的行為,而不必死背。
關鍵術語:連動變體與 v4 新變體
除了元素自身的互動/表單狀態,以下這幾組「關係型」與 v4 新變體先把術語對照清楚:
group-*(父子連動):在父元素加groupclass,子元素用group-hover:、group-focus:等前綴,依「父元素自身的狀態」反應。多組巢狀時用具名 group:父層group/menu、子層group-hover/menu:,以/menu後綴綁定,避免內外層互相干擾(v4 語法)。peer-*(兄弟連動):在某兄弟元素加peerclass,後方兄弟用peer-checked:、peer-invalid:等前綴,依「peer 元素的狀態」反應。同樣支援具名:peer/email、peer-invalid/email:。限制:peer 必須在 DOM 中排在使用者前面。has-*(父依子,v4):對應 CSS:has(),讓父元素依「內部子元素的狀態或存在」反應,如has-[:checked]:bg-blue-50。可與連動組合成group-has-*(依父的後代)與peer-has-*(依兄弟的後代)。not-*(反向,v4):對應:not(),在不符合某條件時才套用,如hover:not-focus:bg-indigo-700(hover 且非 focus 時)、not-supports-[display:grid]:flex(不支援 grid 時退回 flex)。in-*(隱含 group,v4):類似 group 但不需在父層加group,依「任一祖先」的狀態生效,如in-focus:opacity-100(祖先取得焦點時)。寫法更精簡。aria-*/data-*(屬性變體):依 ARIA 或 data 屬性套樣式。內建aria-checked:、aria-expanded:等常用值,自訂用中括號aria-[sort=ascending]:。data-*在 v4 新增 bare value 語法,可直接寫data-active:而非只能用舊的data-[active]:。
實作範例
理論看完,我們用四個高頻範例把「互動按鈕/卡片、peer 表單驗證、group 選單、has-* 版面」串起來。以下都是完整的 HTML + Tailwind,可直接貼進頁面觀察效果。
範例一:完整互動狀態的按鈕與 hover 卡片
按鈕是狀態變體的入門教材:一顆好按鈕應該同時處理 hover、focus、active、disabled 四種狀態。關鍵是每個狀態各給一組視覺回饋:
<!-- 主要按鈕:懸停加深、鍵盤 focus 顯示外框、按壓凹陷、禁用變灰 -->
<button class="
inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium
bg-cyan-600 text-white
hover:bg-cyan-700
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-600
active:bg-cyan-800 active:scale-[0.98]
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-150
">
送出
</button>
這顆按鈕把四個互動狀態一次講清楚。hover:bg-cyan-700 是滑鼠懸停時加深底色;focus-visible: 這組特別重要——它只在「鍵盤 focus」時顯示外框,滑鼠點擊不會觸發,這樣既保留了鍵盤使用者的無障礙外框、又不會讓滑鼠點一下就跳出一圈難看的框(這正是 focus-visible: 存在的理由,別再用 focus: 硬套外框了)。active:bg-cyan-800 active:scale-[0.98] 讓按壓當下底色更深、微微縮小,做出「凹陷」的實體感。disabled:opacity-50 disabled:cursor-not-allowed 則在按鈕被 disabled 時變半透明、游標變禁止符號。最後 transition-all 讓所有狀態切換平滑過渡。
同樣的思路放大到整張卡片,就要靠 group——讓「hover 卡片外框」連動「內部標題變色、箭頭浮現」:
<!-- 父層標 group,hover 整張卡片時內部元素連動變化 -->
<a href="#" class="group block rounded-xl border border-gray-200 p-4
hover:border-cyan-500 hover:shadow-md transition-all">
<h3 class="font-semibold text-gray-900 group-hover:text-cyan-600 transition-colors">
卡片標題
</h3>
<p class="mt-1 text-sm text-gray-500 group-hover:text-gray-700 transition-colors">
hover 整張卡片時,標題與箭頭一起連動
</p>
<span class="mt-2 inline-block text-sm text-cyan-500 opacity-0
group-hover:opacity-100 transition-opacity">
→ 查看詳情
</span>
</a>
重點在:hover 觸發點是外層的 <a>(它標了 group),而變化的是內部三個元素。子元素用 group-hover: 前綴——當使用者 hover 到卡片任何位置,標題變 text-cyan-600、描述變深、原本 opacity-0 的箭頭浮現成 opacity-100。這是純 CSS 就能做到的「整體連動」,不需要任何 JavaScript。
範例二:peer 表單即時驗證
表單驗證是 peer 的經典舞台:讓錯誤訊息「依前方 input 的驗證狀態」自動顯示或隱藏。關鍵是在 input 加 peer,把提示放在它後面用 peer-invalid: 控制:
<!-- input 標 peer,後方訊息依它的 valid/invalid 狀態切換 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
required
placeholder="you@example.com"
class="peer w-full rounded-lg border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-cyan-500
user-invalid:border-red-400 user-valid:border-green-400"
/>
<!-- peer-user-invalid:顯示錯誤、peer-user-valid:顯示成功 -->
<p class="mt-1 hidden text-sm text-red-500 peer-user-invalid:block">
請輸入有效的 Email 格式
</p>
<p class="mt-1 hidden text-sm text-green-500 peer-user-valid:block">
Email 格式正確
</p>
</div>
這個範例把 peer 的連動機制示範得很完整。input 標了 peer,後面兩段提示文字預設都 hidden,分別用 peer-user-invalid:block 與 peer-user-valid:block 控制:當 input 無效時只顯示紅色錯誤、有效時只顯示綠色成功。這裡刻意用 user-invalid: / user-valid: 而非 invalid: / valid:——差別在 user-* 版本只在「使用者實際互動過」之後才判定,避免頁面一載入、欄位還空著就滿屏紅字報錯,體驗好得多。注意 peer 元素必須排在提示訊息前面,因為 CSS 兄弟選擇器只能往後選;若把提示放到 input 前面,peer-* 就完全失效。若同一表單有多個欄位,用具名 peer/email、peer-user-invalid/email: 區分,避免彼此干擾。
範例三:group 下拉選單與具名 group
選單常有巢狀連動需求:hover 整個選單項時背景變色、同時右側箭頭旋轉。當出現「選單內又有子選單」的巢狀 group 時,就得靠具名 group 避免內外層互相觸發:
<!-- 外層 group/menu,內層 group/item,各自獨立連動不打架 -->
<nav class="group/menu rounded-lg border border-gray-200 p-2">
<button class="flex w-full items-center justify-between rounded-md px-3 py-2
text-sm text-gray-700 hover:bg-gray-50">
<span>產品目錄</span>
<!-- 依「外層選單」hover 旋轉箭頭 -->
<svg class="size-4 text-gray-400 transition-transform
group-hover/menu:rotate-180" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 8l5 5 5-5" />
</svg>
</button>
<ul class="mt-1 space-y-1">
<li class="group/item flex items-center gap-2 rounded-md px-3 py-2 text-sm
text-gray-600 hover:bg-cyan-50 cursor-pointer">
<span class="group-hover/item:text-cyan-700">網頁設計</span>
<!-- 依「此列項目」hover 浮現,而非整個選單 -->
<span class="ml-auto text-xs text-cyan-500 opacity-0
group-hover/item:opacity-100">前往 →</span>
</li>
</ul>
</nav>
這個範例的重點在具名 group 解決巢狀衝突。外層 <nav> 標 group/menu,箭頭用 group-hover/menu:rotate-180——只有 hover 到整個選單容器才旋轉;內層每個 <li> 各自標 group/item,項目文字與「前往」箭頭用 group-hover/item:——只在 hover 該列項目時才反應。如果不加名字、全用無名 group-hover:,內外層就會互相觸發:hover 任何一個 <li> 都會讓外層箭頭也跟著轉,亂成一團。用 /menu、/item 綁定各自的觸發來源,層次就乾淨了(具名 group 為 v4 語法,v3 需以任意變體變通)。
範例四:has-* 依子元素反應的版面
前三個範例的連動方向都是「父/兄弟 → 目標」,但有個場景它們做不到:父容器要依「內部子元素的狀態」反應——例如「這一列被勾選就整列高亮」。這正是 v4 has-* 的主場:
<!-- 容器依內部 checkbox 是否勾選、是否有焦點自動反應 -->
<label class="flex items-center gap-3 rounded-lg border border-gray-200 p-3 cursor-pointer
transition-colors
has-[:checked]:border-cyan-500 has-[:checked]:bg-cyan-50
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-cyan-300">
<input type="checkbox" class="size-4 rounded text-cyan-600" />
<span class="text-sm text-gray-700 has-[:checked]:text-cyan-700">
我同意服務條款
</span>
</label>
這個範例是 has-* 的教科書用法。整個 <label> 容器不需要標記任何 group,直接用 has-[:checked]: 讓它「內部 checkbox 一被勾選,就自己變成藍框藍底」;連內部的文字也能用 has-[:checked]:text-cyan-700 同步變色。加上 has-[:focus-visible]:ring-2,當內部元素用鍵盤取得焦點時整個容器發光,無障礙體驗一次到位。這在過去只能靠 JavaScript 監聽 change 事件才能做到,現在純 CSS 一行搞定。
has-* 還能與連動變體組合。group-has-* 讓「依父元素的後代狀態」反應——例如群組內含有連結時才顯示某圖示:
<!-- 父層 group,內部若有 <a> 才顯示外部連結圖示 -->
<div class="group flex items-center gap-2 rounded-lg border p-3">
<span class="font-medium text-gray-900">Spencer Sharp</span>
<!-- group 內部有 <a> 時才顯示 -->
<svg class="hidden size-4 text-cyan-500 group-has-[a]:block"
viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3h6v6M17 3l-8 8" />
</svg>
<p class="text-sm text-gray-500">
Designer at <a href="#" class="text-cyan-600 underline">planeteria.tech</a>
</p>
</div>
這裡外層標 group,圖示用 group-has-[a]:block——只要這個 group 內部存在 <a> 元素,原本 hidden 的圖示就顯示出來。這是 group-has-* 依「父元素的後代結構」反應的典型場景;對稱地,peer-has-* 則依「兄弟元素的後代狀態」反應。
常見錯誤與最佳實踐
五個最容易踩的狀態變體坑
坑一:用了 group-hover: 卻忘了在父層標 group,子元素毫無反應。 這是新手最常見的錯——只寫了子元素的 group-hover:,父層沒有 group 這個「觸發標記」,連動當然不成立。根治法:連動變體是「標記 + 觸發」雙層機制,group-* 務必在父層加 group、peer-* 務必在兄弟加 peer,缺一不生效。
<!-- ❌ 子元素寫了 group-hover: 但父層沒 group,完全不反應 -->
<div class="p-4">
<h3 class="group-hover:text-cyan-600">不會變色</h3>
</div>
<!-- ✅ 父層先標 group -->
<div class="group p-4">
<h3 class="group-hover:text-cyan-600">hover 父層就變色</h3>
</div>
坑二:peer 元素放在使用者「後面」,peer-* 失效。 把錯誤訊息寫在 input 前面、再用 peer-invalid: 想連動,結果完全沒反應。原因:peer-* 底層是 CSS 後續兄弟選擇器 ~,只能「往後」選,peer 元素必須在 DOM 排在使用者前面。根治法:把標 peer 的元素放在使用 peer-*: 的元素之前。
<!-- ❌ 提示在 input 前面,peer-invalid 選不到 -->
<div>
<p class="hidden peer-invalid:block text-red-500">格式錯誤</p>
<input type="email" class="peer" required />
</div>
<!-- ✅ peer 在前、提示在後 -->
<div>
<input type="email" class="peer" required />
<p class="hidden peer-invalid:block text-red-500">格式錯誤</p>
</div>
坑三:用 focus: 硬加外框,滑鼠點一下就跳出難看的框。 想做無障礙外框卻用了 focus:,結果滑鼠點擊也觸發,視覺很干擾。原因:focus: 對滑鼠與鍵盤焦點一律觸發;focus-visible: 才會「只在鍵盤焦點」顯示。根治法:互動元素的外框一律用 focus-visible:,兼顧無障礙與美觀。
<!-- ❌ 滑鼠點擊也跳外框 -->
<button class="focus:outline focus:outline-2">滑鼠點也有框</button>
<!-- ✅ 只在鍵盤 focus 顯示外框 -->
<button class="focus-visible:outline focus-visible:outline-2">鍵盤才有框</button>
坑四:表單一載入就滿屏紅字,因為用了 invalid: 而非 user-invalid:。 必填欄位還空著、使用者根本還沒輸入,就被 invalid: 判定無效、整片變紅。原因:invalid: 一載入就判定;user-invalid:(v4)只在使用者互動過後才判定。根治法:即時驗證的樣式優先用 user-invalid: / user-valid:。
<!-- ❌ 一載入空欄位就變紅,體驗差 -->
<input required class="invalid:border-red-500" />
<!-- ✅ 使用者互動後才判定 -->
<input required class="user-invalid:border-red-500" />
坑五:變體堆疊順序亂寫,行為與預期不符。 隨手把 dark:md:hover: 寫成 hover:dark:md:,在牽涉巢狀選擇器結構的變體上可能真的產生不同 CSS。原因:變體是層層包裹的巢狀選擇器,順序影響生成結構。根治法:全專案統一照「響應式 → 主題 → 狀態」由外而內書寫,例如 dark:md:hover:text-white。
<!-- ✅ 慣用順序:響應式 → 主題 → 狀態,由外而內 -->
<a class="text-gray-600 dark:text-gray-400 md:hover:underline dark:hover:text-gray-100">
多層組合連結
</a>
狀態變體最佳實踐清單
- 連動變體務必雙層齊全:
group-*要在父層加group、peer-*要在兄弟加peer,缺標記就不生效。 peer必須排在使用者前面:CSS 兄弟選擇器只能往後選,提示訊息要放在標peer的元素之後。- 外框用
focus-visible:而非focus::只在鍵盤焦點顯示,兼顧無障礙與滑鼠美觀。 - 即時驗證用
user-invalid:/user-valid::避免頁面一載入空欄位就滿屏報錯(v4 內建)。 - 父依子反應用
has-*:選中高亮、容器發光等過去要 JS 的場景,has-[:checked]:、has-[:focus-visible]:純 CSS 搞定(v4;需較新瀏覽器,v3 無此變體)。 - 巢狀連動用具名 group/peer:
group/menu、peer/email綁定各自觸發來源,避免內外層互相干擾(v4 語法)。 - 堆疊順序固定「響應式 → 主題 → 狀態」:全專案一致由外而內書寫,需精準控制位置時再用任意變體
[&:...]。 - 善用
aria-*/data-*反映元件狀態:排序箭頭、展開/收合、選中態用屬性變體,樣式與狀態同源好維護(data-*的 bare value 語法為 v4 新增)。
小結
這是 TailwindCSS 完整教學 系列的第二十五篇,也是 TW-5「響應式與狀態」 章節的下半場。上一篇 《響應式設計》 讓版面「隨螢幕大小伸縮」;而這一篇,我們把視野從「看螢幕大小」延伸到「看使用者動作」,讓介面真正活起來。回顧幾個重點:
- 變體=條件式選擇器:每個前綴只是幫 utility 加一個生效條件,
hover:bg-blue-700就是「只在:hover時套用」的普通 CSS,理解這點就能自行推導任何組合。 - 互動與表單狀態:
hover:/active:顧回饋、focus-visible:顧無障礙外框、disabled:顧禁用態、user-invalid:顧即時驗證。 - 連動變體雙層機制:
group-*父子連動(父標group)、peer-*兄弟連動(兄弟標peer、且必須排前面),巢狀用具名group/name。 - v4 新變體:
has-*讓父依子反應(選中高亮、容器發光)、not-*反向條件、in-*免標記 group,還有group-has-*/peer-has-*組合。 - 堆疊順序:統一照「響應式 → 主題 → 狀態」由外而內書寫,團隊一致又不踩雷。
掌握了狀態變體,你已經能讓介面隨著每一個互動動作即時回應。但目前為止,我們的畫面都預設在明亮的淺色底上——現代網站幾乎都需要提供暗色模式(Dark Mode),讓使用者在夜間或偏好深色時獲得舒適的閱讀體驗,而這又和主題切換、系統偏好偵測、dark: 變體息息相關。下一篇 《暗色模式》 會帶你認識 dark: 變體的運作、v4 的 @custom-variant 切換策略,以及如何用 CSS 變數打造一套能一鍵切換明暗的設計系統,讓你的介面在白天與黑夜都同樣好看。