表單模式:input/驗證/自訂 checkbox 的優雅寫法 | TailwindCSS 完整教學

2026/09/03
表單模式:input/驗證/自訂 checkbox 的優雅寫法 | TailwindCSS 完整教學

表單(Form) 是最考驗一致性的 UI——原生元素在各瀏覽器長得七零八落,狀態(focus/disabled/invalid)又多。這一篇用 TailwindCSSinputtextareaselectcheckboxradio 一路樣式化,搭配 @tailwindcss/forms plugin 重置預設外觀,再用 focus:disabled:user-invalid: 等狀態變體與 accent-color 做出優雅又無障礙的驗證回饋 UI。

前言

表單(Form) 是使用者與網站對話的主要介面——登入、註冊、結帳、搜尋,幾乎每個關鍵動作都經過一個表單。但表單也是前端最難做得漂亮的一塊:<input><select><checkbox> 這些原生元素帶著平台相依的預設樣式,同一段程式碼在 Chrome、Safari、Firefox 上長得完全不一樣;再加上 focus(聚焦)、disabled(禁用)、invalid(驗證失敗)等多種狀態要各自呈現,一個看似簡單的輸入框,背後其實藏著不少細節。

先用一個生活化的類比。表單樣式化就像幫一群穿著各家制服的新人換上公司統一服裝:原生元素是各校畢業、制服各異的新人(瀏覽器預設樣式),@tailwindcss/forms 是那套發下去的基本款制服(把大家先拉齊到同一個乾淨起點),而 Tailwind 的 utility 則是你再往上繡的名牌與配色(品牌色、圓角、陰影)。少了統一制服這一步,你得一個一個手動幫新人改衣服(對每個元素寫 appearance-none 再重刻),費工又容易漏。

本系列以 TailwindCSS v4 為預設版本。本篇你將學到:

  • 表單元素樣式化:inputtextareaselectcheckboxradio 的統一樣式與 focus/disabled 狀態
  • @tailwindcss/forms plugin:v4 用 @plugin 引入、base 與 class 兩種策略的取捨
  • 驗證回饋 UI:用 user-invalid:aria-invalidpeer 做出不過早報錯的即時驗證
  • 自訂 checkbox / toggle 與無障礙:accent-color 一行換色,以及何時該重刻、a11y 要補什麼

本系列以 TailwindCSS v4 為預設版本,範例皆以 v4 語法示範。凡涉及 v3 差異之處,我會特別標註。

核心概念

為什麼原生表單這麼難搞:Preflight 與 forms plugin

要理解表單樣式,得先分清楚 Tailwind 的兩層重置。第一層是 Preflight——Tailwind 內建的 base reset,它會拿掉 margin、統一 box-sizing,也讓 buttoninput 繼承字型,但它刻意不去動表單元素的「外觀」:checkbox 還是原生方塊、select 還是原生箭頭、input 邊框還是各瀏覽器自己的樣子。這是因為外觀是主觀設計決策,Tailwind 不想替你決定。

於是就有了第二層:@tailwindcss/forms。這個官方 plugin 專門補上 Preflight 沒做的事——它用 appearance-none 之類的手法,把表單元素拉齊到一個無主觀色彩、乾淨、可預測的基礎樣式,讓你之後用 utility 疊加時,各瀏覽器表現一致。可以這樣記:Preflight 讓「排版」歸零,forms plugin 讓「表單外觀」歸零

在 v4 裡引入 plugin 不再需要 tailwind.config.js,直接在 CSS 用 @plugin 指令:

/* globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/forms";

plugin 有兩種策略。預設是 base 策略:自動對所有 inputselecttextarea 等元素套用基礎樣式,你什麼 class 都不用加就生效——適合你能完全掌控的專案。另一種是 class 策略,只對明確加了 form-inputform-selectform-checkbox 等 class 的元素生效:

/* class 策略:只對加了 form-* class 的元素重置,避免影響第三方元件 */
@plugin "@tailwindcss/forms" {
  strategy: class;
}

當你的頁面混用了第三方 UI 元件庫,擔心 base 策略的全域重置會干擾它們時,class 策略能把重置範圍縮到你指定的元素,是更安全的選擇。

該怎麼選?一個簡單的判斷準則:整個專案的表單都由你掌控、沒有外來元件,就用預設的 base 策略,省下每個元素加 class 的麻煩;頁面會嵌入你無法控制樣式的第三方元件(某個日期選擇器、某個編輯器內部的 input),就改用 class 策略,把重置範圍限縮到你自己的元素。這其實呼應了《設計系統建構》談過的原則——副作用的範圍越小、越可預測,系統越好維護。base 策略像一條「預設全開」的水管,方便但流得到處都是;class 策略像手動閥門,多一點設定卻換來精準的控制權。

狀態變體與 v4 新特性對照

表單的靈魂在「狀態」。Tailwind 用**狀態變體(state variant)**前綴把不同狀態的樣式寫在同一個 class 屬性裡,以下是表單最常用的一組,以及 v4 帶來的新工具:

變體 / utility對應用途
focus: / focus-visible::focus / :focus-visible聚焦樣式;focus-visible 只在鍵盤聚焦時顯示,不干擾滑鼠點擊
focus-within::focus-within內部子元素聚焦時,讓「外框容器」變樣(帶圖示的輸入組合常用)
disabled::disabled禁用態:降透明度、cursor-not-allowed
read-only::read-only唯讀態:可聚焦、值會提交,但不可編輯
required::required必填欄位
checked::checkedcheckbox / radio 勾選態
invalid::invalid不符驗證條件即生效(會過早報錯)
user-invalid::user-invalidv4 新增:使用者互動後才判定無效,解決過早報錯
accent-*accent-color一行換掉原生 checkbox/radio/range 的主色
field-sizing-contentfield-sizingv4 新增:讓 textarea 依內容自動長高,免寫 JS

這裡三個 v4 的重點值得特別記:

  • user-invalid::傳統 invalid: 的痛點是「頁面一載入,還沒填的 required 欄位就被標紅」。user-invalid: 對應 CSS :user-invalid,只有在使用者互動過後(輸入並離開,或按下送出)才生效,是純 CSS 即時驗證體驗的關鍵升級。
  • accent-color:透過 accent-cyan-500 這類 utility,一行就能把原生 checkbox、radio、range 的主色換成品牌色,而且完全保留原生的鍵盤與無障礙行為,是自訂 checkbox 最省力的第一選擇。
  • field-sizing-content:讓 textarea 隨輸入內容自動增高,過去要靠 JS 監聽才能做到的效果,現在一個 utility 搞定(注意瀏覽器支援度,舊環境需優雅降級)。

關鍵術語:peer 是 Tailwind 的「兄弟元素選擇」機制——在某元素加 peer,它的後方兄弟元素就能用 peer-invalid:peer-focus:peer-checked: 依前者的狀態變樣。它是「input 錯誤時顯示下方錯誤訊息」「隱藏 input 驅動自訂 toggle」這兩個經典模式的技術核心。要注意 peer 只能向後選取兄弟,不能往上選父層或往前選;這個限制在排版時很重要——想讓錯誤訊息顯示在 input 下方,錯誤訊息就必須寫在 input 之後,否則選不到。

順帶一提,若你需要的是「容器內任一子元素聚焦時,整個外框變樣」,那要用的是 focus-within: 而不是 peer。這在「輸入框加左側圖示」或「input 搭配右側單位文字」這類組合輸入特別實用:把 focus-within:ring-2 加在外層 div,不論使用者點進哪個內部元素,整個組合框都會一起亮起聚焦樣式,視覺上更像一個完整的控制項。peer 管兄弟、focus-within: 管子孫,兩者搭配起來,幾乎能覆蓋純 CSS 表單互動的所有需求。

實作範例

理論看完,我們動手做三個最有代表性的表單模式:一個完整登入表單、一組含即時驗證回饋的欄位,以及自訂 checkbox 與 toggle。前提是你已在 globals.css 引入 Tailwind 與 forms plugin。

範例一:完整登入表單(label 對齊與群組)

先看整體版面。表單版面的兩個基本功是:用 space-y-* 拉開欄位間距,以及每個欄位用「label 在上、input 在下」的縱向 flex 群組,讓標籤與輸入框對齊:

<form novalidate class="w-full max-w-sm space-y-6">
  <!-- 電子郵件欄位:label + input 的縱向群組 -->
  <div class="flex flex-col gap-1.5">
    <label for="email" class="text-sm font-medium text-gray-700">電子郵件</label>
    <input
      type="email"
      id="email"
      name="email"
      autocomplete="email"
      placeholder="you@example.com"
      class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900
             placeholder:text-gray-400
             focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/40 focus:outline-none
             disabled:cursor-not-allowed disabled:bg-gray-50
             transition-colors"
    />
  </div>

  <!-- 密碼欄位:label 與「忘記密碼」用 justify-between 分置兩端 -->
  <div class="flex flex-col gap-1.5">
    <div class="flex items-center justify-between">
      <label for="password" class="text-sm font-medium text-gray-700">密碼</label>
      <a href="#" class="text-xs font-medium text-cyan-600 hover:underline">忘記密碼?</a>
    </div>
    <input
      type="password"
      id="password"
      autocomplete="current-password"
      class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm
             focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/40 focus:outline-none
             transition-colors"
    />
  </div>

  <!-- 記住我:label 包住 input,整塊可點,accent 換色 -->
  <label class="flex items-center gap-2">
    <input type="checkbox" class="size-4 rounded accent-cyan-600" />
    <span class="text-sm text-gray-600">記住我</span>
  </label>

  <button
    type="submit"
    class="w-full rounded-lg bg-cyan-600 px-4 py-2.5 text-sm font-medium text-white
           hover:bg-cyan-700 active:bg-cyan-800
           focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-600
           disabled:opacity-50 disabled:cursor-not-allowed
           transition-colors">
    登入
  </button>
</form>

幾個關鍵細節值得拆解。focus: 的組合拳focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/40 focus:outline-none——先移除瀏覽器預設的 outline,再用 borderring(帶 /40 透明度的柔和光暈)做出自訂聚焦樣式。按鈕特意用 focus-visible: 而非 focus:,這樣滑鼠點擊時不會出現聚焦框,只有鍵盤 Tab 過來才顯示,兼顧美觀與鍵盤無障礙。label 直接包住 checkbox 是重要的無障礙寫法:整個文字區都變成可點擊範圍,也天然建立了標籤與控制項的關聯,不需要額外的 for/id

範例二:即時驗證回饋(user-invalid + peer)

這是本篇最實用的模式:欄位在使用者互動後才驗證,錯誤訊息只在該欄位無效時出現,且不會在頁面剛載入時就整片標紅。做法是把 user-invalid: 用在 input,再用 peer 讓下方的錯誤訊息監看 input 狀態:

<div class="flex flex-col gap-1.5">
  <label for="signup-email" class="text-sm font-medium text-gray-700">
    電子郵件 <span aria-hidden="true" class="text-red-500">*</span>
  </label>

  <!-- peer:標記為參照元素;user-invalid: 只在互動後才判定無效 -->
  <input
    type="email"
    id="signup-email"
    required
    aria-describedby="email-error"
    placeholder="you@example.com"
    class="peer w-full rounded-lg border border-gray-300 px-3 py-2 text-sm
           focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/40 focus:outline-none
           user-invalid:border-red-500 user-invalid:ring-red-500/30
           transition-colors"
  />

  <!-- 只有當 peer(input)處於 user-invalid 時才顯示這段錯誤 -->
  <p
    id="email-error"
    role="alert"
    class="hidden text-xs text-red-600 peer-user-invalid:block"
  >
    請輸入有效的電子郵件地址
  </p>
</div>

這段的巧妙在於全程不需要一行 JavaScriptuser-invalid: 讓輸入框在使用者填錯並離開後才變紅框;而 peer-user-invalid:block 讓原本 hidden 的錯誤訊息在同一時機浮現。對照舊寫法用 invalid: / peer-invalid:,頁面一開就會把空的必填欄位標紅,:user-invalid 正是為了修掉這個惱人體驗而生。

無障礙方面有兩個必做項:aria-describedby="email-error" 把輸入框和錯誤訊息綁定,螢幕閱讀器聚焦到欄位時會一起讀出錯誤;錯誤訊息加 role="alert",讓它出現時被即時朗讀。若你另外用 JavaScript 做驗證,記得同步設定 aria-invalid="true",並可用 aria-invalid:border-red-500 這類 variant 讓視覺跟著屬性走,和 user-invalid: 互補。

至於 textarea,v4 可以直接讓它隨內容長高,免掉監聽 input 事件手動調高度的 JS:

<textarea
  rows="3"
  placeholder="留下你的訊息..."
  class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm
         field-sizing-content resize-none
         focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/40 focus:outline-none"
></textarea>

field-sizing-contenttextarea 的高度跟著文字增減,搭配 resize-none 收掉手動拖曳的把手,體驗更俐落。這是新特性,舊瀏覽器會退回一般 textarea 行為,屬於安全的漸進增強。

範例三:自訂 checkbox 與 toggle switch

換色最省力的方式是 accent-*,一行就能把原生 checkbox / radio 的主色換成品牌色,原生的鍵盤與無障礙行為原封不動:

<fieldset>
  <legend class="mb-3 text-sm font-medium text-gray-700">通知方式(可複選)</legend>
  <div class="space-y-2">
    <label class="flex items-center gap-3">
      <input type="checkbox" name="notify" class="size-4 rounded accent-cyan-600" />
      <span class="text-sm text-gray-700">電子郵件通知</span>
    </label>
    <label class="flex items-center gap-3">
      <input type="checkbox" name="notify" class="size-4 rounded accent-cyan-600" />
      <span class="text-sm text-gray-700">簡訊通知</span>
    </label>
    <!-- 禁用態:整個 label 灰化 -->
    <label class="flex items-center gap-3 opacity-50">
      <input type="checkbox" disabled class="size-4 rounded accent-cyan-600" />
      <span class="text-sm text-gray-500">推播通知(即將推出)</span>
    </label>
  </div>
</fieldset>

注意這裡用 <fieldset> + <legend> 把一組相關選項語意化地圈在一起,這對螢幕閱讀器理解「這幾個選項是同一組」很重要,是 checkbox / radio 群組的標準無障礙結構。

當設計需要原生做不到的效果(例如 toggle 開關),才需要動用 sr-only 隱藏原生 input,再用 peer-checked: 驅動自訂視覺。sr-only 的關鍵是:input 在畫面上看不見,但仍存在於無障礙樹中,鍵盤與螢幕閱讀器照樣能操作它:

<label class="inline-flex items-center gap-3">
  <span class="text-sm font-medium text-gray-700">開啟通知</span>

  <span class="relative inline-block">
    <!-- sr-only:視覺隱藏但保留 a11y 與鍵盤操作 -->
    <input type="checkbox" class="peer sr-only" />

    <!-- 軌道:勾選時變品牌色 -->
    <span
      class="block h-6 w-11 rounded-full bg-gray-300
             peer-checked:bg-cyan-600
             peer-focus-visible:ring-2 peer-focus-visible:ring-cyan-500/50
             transition-colors"
    ></span>

    <!-- 滑塊:勾選時右移 -->
    <span
      class="absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow
             peer-checked:translate-x-5
             transition-transform"
    ></span>
  </span>
</label>

toggle 的原理:peer 讓真正的 checkbox 成為狀態來源,軌道用 peer-checked:bg-cyan-600 換色、滑塊用 peer-checked:translate-x-5 位移,peer-focus-visible:ring-* 則補回鍵盤聚焦時的可見焦點框——這個焦點框千萬別省,否則鍵盤使用者會完全看不出焦點在哪。整個 toggle 因為底層是真的 <input type="checkbox">,表單會正常送出它的值,鍵盤空白鍵也能切換,無障礙先天成立。

自訂 checkbox / toggle 有一條貫穿始終的判斷線,值得記牢:要不要接手,取決於你願不願意連 a11y 一起接手。用 accent-* 換色時,原生的鍵盤操作、焦點框、螢幕閱讀器語意全都免費附送;一旦用 appearance-nonesr-only 把原生外觀藏起來自己重刻,這些「免費的東西」就全部變成你的責任——焦點框要自己補(peer-focus-visible:ring-*)、鍵盤切換要確認仍可用(用真的 input 而非純 div 就能保住)、狀態要讓輔助技術讀得到。很多漂亮但不可用的自訂 toggle,問題都出在只顧了視覺、忘了把這些責任接回來。

至於 select,若沒裝 forms plugin,得自己 appearance-none 再補一個自訂箭頭;裝了 plugin 後基礎樣式已統一,你只要疊加 border、padding 與 focus 樣式即可,省下最惱人的跨瀏覽器箭頭問題——這也再次印證了開頭那句:能交給 plugin 的,就別自己刻

常見錯誤與最佳實踐

坑一:硬刻原生表單樣式,不用 forms plugin

最常見的時間黑洞,就是不裝 @tailwindcss/forms、硬要對每個 checkboxradioselect 手寫 appearance-none 加自訂樣式。這不只費工,還很容易在某個瀏覽器出現破圖(尤其 Safari 的 select 箭頭與 checkbox 邊框特別頑固)。正確做法:先用 plugin 把地基拉平,再用 utility 疊加品牌樣式;只有在 plugin 也滿足不了的高度客製(自訂勾勾動畫、toggle)才走 appearance-none 重刻。記住重刻的成本不只是視覺,還包含要自己補回原生本來就有的鍵盤操作與焦點樣式。

坑二:用 invalid: 導致頁面一開就整片標紅

直接用 invalid:peer-invalid: 做即時驗證,會讓所有空的 required 欄位在頁面剛載入、使用者還沒動手時就通通標紅,像在指責使用者還沒犯的錯:

<!-- ❌ 頁面一載入,空的必填欄位立刻變紅框 -->
<input required class="invalid:border-red-500" />

正確做法是改用 v4 的 user-invalid:,它只在使用者互動過後才判定,把報錯時機挪到「該報的時候」:

<!-- ✅ 只有使用者填過又離開/送出後,無效才標紅 -->
<input required class="user-invalid:border-red-500" />

坑三:label 與 input 沒有關聯

只把文字放在 input 旁邊、卻沒建立關聯,是最常見也最傷無障礙的錯。這樣螢幕閱讀器聚焦到欄位時讀不出這是什麼欄位,點文字也無法聚焦到輸入框:

<!-- ❌ 純視覺相鄰,無語意關聯 -->
<span class="text-sm">電子郵件</span>
<input type="email" />

正確做法有兩種,擇一即可:用 for/id 明確配對,或直接讓 <label> 包住 input(範例一的「記住我」就是這種)。兩種都能讓「點標籤 = 聚焦輸入框」,並讓輔助技術正確朗讀:

<!-- ✅ 寫法 A:for 對應 id -->
<label for="email" class="text-sm font-medium">電子郵件</label>
<input id="email" type="email" />

<!-- ✅ 寫法 B:label 包住 input,免 id -->
<label class="text-sm font-medium">
  電子郵件 <input type="email" />
</label>

坑四:移除 outline 卻不補聚焦樣式

為了美觀寫 focus:outline-none不補上任何替代的聚焦樣式,是無障礙的重罪——鍵盤使用者會完全喪失「我現在在哪個欄位」的視覺線索:

<!-- ❌ 拿掉 outline 又不補,鍵盤使用者迷路 -->
<input class="focus:outline-none" />

正確做法:移除預設 outline 的同時,一定用 ringborder 補一個清晰可見的聚焦樣式;互動元件(按鈕)則優先用 focus-visible:,只在鍵盤聚焦時顯示,兼顧滑鼠使用者的視覺乾淨:

<!-- ✅ 移除 outline 的同時補上 ring -->
<input class="focus:outline-none focus:ring-2 focus:ring-cyan-500/40" />

最佳實踐小結

  • 地基交給 plugin:用 @tailwindcss/forms 拉平原生外觀,不自己硬刻。
  • 驗證用 user-invalid::純 CSS 即時回饋不過早報錯;JS 驗證同步 aria-invalid
  • 換色優先 accent-*:能一行換色就別重刻,重刻越多要補的 a11y 越多。
  • label 一定要關聯:for/idlabel 包住 input,群組用 fieldset/legend
  • 聚焦樣式不可省:移除 outline 必補 ring,按鈕用 focus-visible:

小結

這是 TailwindCSS 完整教學 系列的第三十四篇。上一篇《設計系統建構》,我們把 Token、utility、元件組裝成一條完整的生產線;這一篇,我們把那套系統套進了最考驗一致性的場景——表單,看它如何讓瑣碎的輸入框、驗證狀態與錯誤提示也保持優雅。回顧幾個重點:

  • 兩層重置:Preflight 讓排版歸零、@tailwindcss/forms 讓表單外觀歸零,v4 用 @plugin 一行引入,並有 base / class 兩種策略。
  • 狀態變體:focus:focus-visible:focus-within:disabled:checked: 是表單的靈魂,v4 的 user-invalid: 解決過早報錯、field-sizing-content 讓 textarea 自動長高。
  • 驗證與自訂:user-invalid: + peer 做出零 JS 的即時回饋;accent-* 一行換色,真要重刻才用 sr-only + peer-checked: 做 toggle。
  • 無障礙:label 一定要關聯、群組用 fieldset/legend、錯誤用 aria-describedby + role="alert"、移除 outline 必補聚焦樣式。

把表單做好,你就掌握了 UI 一致性最硬的一塊骨頭。接下來,我們要把目光從「單一頁面的輸入」移到「跨頁面的移動」——下一篇《導覽模式》,將帶你用 TailwindCSS 打造導覽列、側邊欄、麵包屑與行動選單,看設計系統如何在整站的動線上延續一致的體驗。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →