TailwindCSS 狀態變體:hover、group、peer 與 v4 has-* 全解 | TailwindCSS 完整教學

2026/08/25
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-*(父子連動):在父元素加 group class,子元素用 group-hover:group-focus: 等前綴,依「父元素自身的狀態」反應。多組巢狀時用具名 group:父層 group/menu、子層 group-hover/menu:,以 /menu 後綴綁定,避免內外層互相干擾(v4 語法)。
  • peer-*(兄弟連動):在某兄弟元素加 peer class,後方兄弟用 peer-checked:peer-invalid: 等前綴,依「peer 元素的狀態」反應。同樣支援具名:peer/emailpeer-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:blockpeer-user-valid:block 控制:當 input 無效時只顯示紅色錯誤、有效時只顯示綠色成功。這裡刻意用 user-invalid: / user-valid: 而非 invalid: / valid:——差別在 user-* 版本只在「使用者實際互動過」之後才判定,避免頁面一載入、欄位還空著就滿屏紅字報錯,體驗好得多。注意 peer 元素必須排在提示訊息前面,因為 CSS 兄弟選擇器只能往後選;若把提示放到 input 前面,peer-* 就完全失效。若同一表單有多個欄位,用具名 peer/emailpeer-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-* 務必在父層加 grouppeer-* 務必在兄弟加 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-* 要在父層加 grouppeer-* 要在兄弟加 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/menupeer/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 變數打造一套能一鍵切換明暗的設計系統,讓你的介面在白天與黑夜都同樣好看。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →