互動與體驗設計指南

💡 本文是人類閱讀版(決策時讀、理解 why)。對應的寫程式護欄在開發規範的 UX-001~UX-010(已 sync 進專案 CLAUDE.md,AI 寫程式時強制遵守)。兩者同主題、兩層:這裡講透「為什麼」,護欄給「照著做」。

互動細節統一,是「專業感」最廉價、也最有效的來源。使用者不會逐條說出「這個按鈕 hover 怎麼和那個不一樣」,但會模糊地覺得「這站做得糙、不太可信」。本文把 swift-web 推薦的互動體驗基線講清楚,並給出在 framework 元件上的落地方式。

為什麼一致性 = 專業感

使用者在產品裡會建立預期:hover 一個紅按鈕看到它變暗,大腦就預設「所有紅按鈕 hover 都變暗」。這叫可預測性(predictability)。

一旦同類元素表現不一(這裡變亮、那裡變暗),使用者每遇到一次「咦怎麼不一樣」,就要多花一次**認知(cognitive load)**去重新理解。單次微不足道,但全站累積起來,體感就是「不精緻」。反過來,嚴格一致讓介面「隱形」—— 使用者不用思考互動本身,注意力全留給內容。

這正是 Nielsen 可用性十大啟發式裡**「Consistency and standards(一致性與標準)」**的核心。下面四組就是把這條原則落到 swift-web 的具體場景。

一、回饋與狀態

1. 互動回饋一致性:同類元素同表現(UX-001)

同類互動元素(按鈕 / 連結 / 卡片)的 hover / active / focus 回饋必須全站一致。回饋方式按填充類型分三檔:

填充類型hover 回饋為什麼
實色填充(品牌色 / 淺色 / accent / 彩色圖示)變暗 brightness(0.92)(active 0.85)絕大多數按鈕;變暗在實色上永遠可感知
深色 / 半透明深色填充(深背景上)變亮 brightness(1.12)深色上再變暗會沉進背景看不見,需變亮
半透明純白 ghost提高背景透明度(如 12% → 22%)純白已到最大值,brightness 推不動它

最容易踩的兩個坑:

/* ❌ 同一組品牌紅 CTA,登入頁變暗、付費牆變亮 —— 同種按鈕兩種行為,使用者察覺「不一樣」 */
.login-submit:hover { filter: brightness(0.92); } /* 變暗 */
.paywall-cta:hover  { filter: brightness(1.08); } /* 變亮,不一致 */

/* ❌ 半透明白底按鈕想靠 brightness 提亮 —— 純白(255)乘任何倍數仍被 clamp 回 255,hover 毫無回饋 */
.ghost-btn { background: rgb(255 255 255 / 12%); }
.ghost-btn:hover { filter: brightness(1.08); } /* 零效果 */
/* ✅ 按填充類型對檔,全站統一 */
.cta:hover  { filter: brightness(0.92); }
.cta:active { filter: brightness(0.85); }

.dark-btn:hover { filter: brightness(1.12); }

.ghost-btn { background: rgb(255 255 255 / 12%); transition: background 0.15s; }
.ghost-btn:hover { background: rgb(255 255 255 / 22%); }

這條規則的由來:一次真實的付費牆開發裡,品牌紅 CTA 在付費牆寫成了變亮、而全站其它地方都是變暗 —— 同一種紅按鈕兩種 hover。統一回變暗後,「專業感」立刻回來了。CSS 寫法(filter: brightness vs 背景透明度)的完整說明見樣式與 Token。

2. 非同步操作必須鎖住入口(UX-002)

任何非同步操作(提交 / 上傳 / 拉取 / 刪除)觸發的可點元素,在 async 進行期間必須停用或顯 loading,否則使用者能反覆點 → double-submit、狀態錯亂、重複請求。

用 SwButton 時配 :loading 即可,元件會自動停用並顯示載入態:

<SwButton :loading="submitting" @click="onSubmit">提交</SwButton>
<script setup>
const submitting = ref(false)
async function onSubmit() {
  submitting.value = true
  try {
    await api.submit()
  } finally {
    submitting.value = false
  }
}
</script>

非按鈕的可點元素(卡片點擊、自訂觸發)同理 —— pending 期把入口鎖住。

3. 資料驅動視圖必須備齊四態(UX-003)

「拉資料再渲染」的視圖(列表 / 詳情 / feed)只畫成功態,會在另外三態翻車:載入時白屏、空資料一片空白讓人以為壞了、請求失敗卡死沒重試。四態都要畫:

  • loading → 骨架屏(優先,與真實內容等尺寸,順帶防 CLS)或 spinner,別白屏
  • empty → 明確空狀態文案 + 下一步引導(「還沒有內容,去逛逛」+ 入口),別只留空白
  • error → 錯誤說明 + 重試入口,別卡死或靜默
  • success → 正常渲染

framework 多個元件已內建四態鉤子,優先用:SwGallery(loading / error / finished)、SwVideoFeed(finished + 內建逐條載入 / 緩衝指示器,卡頓回饋無需業務自己畫)。

<template>
  <SkeletonList v-if="pending" />
  <ErrorState v-else-if="error" @retry="refresh" />
  <EmptyState v-else-if="!list.length" />
  <ContentList v-else :items="list" />
</template>

二、無障礙與可操作

無障礙不是「給少數人的額外功能」—— 鍵盤使用者、讀屏使用者、前庭敏感使用者都是真實使用者,下面幾條還直接對應 WCAG 標準條款。

1. 尊重 prefers-reduced-motion(UX-004)

位移 / 縮放 / 視差 / 自動輪播 / 大幅過渡等動效,對前庭功能障礙使用者會引發暈眩、噁心(WCAG 2.3.3 Animation from Interactions)。非必要動效必須在 @media (prefers-reduced-motion: reduce) 下關閉或降級:

.card { transition: transform 0.3s; }
.card:hover { transform: scale(1.05) translateY(-8px); }

@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }
  .card:hover { transform: none; } /* 或僅留極輕微 opacity 回饋 */
}

必要的狀態回饋(loading 轉圈等)可保留,但應克制。

2. 彈層必須做焦點管理(UX-005)

模態彈層(modal / dialog / 抽屜)若不管焦點 —— 打開後焦點仍在背景、鍵盤 Tab 跑到被遮擋內容、關閉後焦點遺失 —— 鍵盤和讀屏使用者就完全無法操作。合規要求:打開時焦點移入彈層、內部 Tab 不逃逸(focus trap)、Esc 可關、關閉時焦點還原到觸發元素。

好消息:SwModal / useModal() 基於 reka-ui,這些開箱即合規。所以——用框架彈層就對了,別自己寫 div overlay(自建必須自己實現 trap + restore,很容易漏)。

3. 觸控熱區達標(≠ 撐大視覺)+ focus 指示不 reflow(UX-006)

  • 要約束的是「熱區」,不是視覺尺寸:行動端點擊區太小會頻繁誤觸,但規範要求的是可點區域 —— WCAG 對 target 的定義是「可接受指標操作的區域」,Material Design 3 也明確熱區「extending beyond the visual bounds of an element」。合規基線是 WCAG 2.5.8 AA 的 24×24(44×44 出自 AAA 的 2.5.5),且 inline 文字連結明文豁免(正文內連 / 麵包屑 / 頁腳連結不受最小尺寸約束)。
  • framework 不做全域 min-height 兜底(framework#265 移除;曾做過,導致圖示鈕被撐大、麵包屑錯位)。視覺小於 44px 的緊湊控件加 sw-hit-area class 主動擴熱區、視覺不變;framework 自帶緊湊圖示鈕已在元件內部掛好,用 Sw* 元件無需觸控端補丁。見樣式與 Token · 響應式工具類。
  • focus 指示不 reflow:focus 視覺指示不要靠新增 / 加寬 border 實現(會改盒模型尺寸、擠動版面)。已有 border 的改 border-color,無 border 的用 outline 或 box-shadow ring。

兩條的 CSS 細節見樣式與 Token。

三、導航與確認

1. 破壞性操作必須二次確認(UX-007)

刪除 / 清空 / 登出 / 不可逆扣費等破壞性操作,點擊即執行 = 誤觸一次就造成無法挽回的損失。

  • 高風險 / 不可逆 → 執行前二次確認,用 useModal() 確認框(別用原生 confirm():樣式不可控、阻塞主執行緒、行動端體驗差):
    async function onDelete() {
      const ok = await useModal().open({ title: '確認登出?', description: '此操作不可恢復' })
      if (ok) await api.deleteAccount()
    }
    
  • 低風險 / 可撤銷(封存、移除單項)→ 可用「先執行 + 撤銷 toast」替代硬確認,少打斷使用者。

2. 回饋管道按場景選(UX-008)

回饋管道用錯場景會讓回饋失效 —— 把需要決策的內容塞進一閃而過的 toast、或用 toast 報欄位校驗錯(使用者來不及看)。按場景選:

場景用為什麼
成功 / 輕量提示(非阻斷)toast —— useToast()短暫、不打斷、自動消失
需使用者決策 / 確認modal —— useModal()阻斷、需主動回應、不會錯過
欄位級校驗錯誤inline 就近顯示 —— SwFormField錯在哪顯在哪、可反覆看

原生 alert() 一律不用(樣式不可控、阻塞)。

// ❌ 表單校驗失敗彈 toast —— 一閃而過,使用者沒看清就消失
useToast().error('手機號格式不對')

// ✅ 欄位錯就近 inline,提交成功才 toast
setFieldError('phone', '手機號格式不對')
// ...提交成功後:
useToast().success('已儲存')

四、感知效能

「感知效能」關心的是使用者感覺快不快,不只是真實載入時間。

1. 防版面跳動(CLS)(UX-009)

圖片 / 廣告 / 懶載入內容 / web 字型到達時擠動已渲染內容(Cumulative Layout Shift)—— 使用者正要點的按鈕被推走、閱讀位置亂跳,體感很差,行動端 Lighthouse 也明顯掉分。

核心做法:任何非同步插入的內容都預留佔位尺寸。圖片用 SwImage 傳 ratio(元件用 aspect-ratio 撐住容器);廣告位給固定高;骨架屏與真實內容等尺寸。

<!-- ratio 撐住高度,圖片載入前後容器不跳 -->
<SwImage :src="cover" alt="封面" ratio="16/9" />

2. 樂觀更新(opt-in)(UX-010)

按讚 / 收藏 / 關注等低風險高頻操作,如果每次都等網路往返才變 UI,體感會遲鈍。可以樂觀更新:先改 UI 再發請求,失敗回滾 + 提示。

// ✅ 按讚樂觀 + 失敗回滾
liked.value = true
try {
  await api.like(id)
} catch {
  liked.value = false
  useToast().error('操作失敗,請重試')
}

邊界(重要):只有低風險、可回滾的操作適用。有副作用 / 不可逆 / 涉及金額的操作(支付、刪除、發布、提交訂單)絕不要樂觀 —— 必須等伺服器確認,否則「樂觀顯示成功、實際失敗」會誤導使用者、釀成事故。

業界理論速覽

本文只講 swift-web 的可執行落地。想深入理解通用 UX / 無障礙理論,看下面這些權威源(本站不複製其內容,避免過時):

與開發護欄的關係

本文(人類長文)與開發規範裡的 UX-001~UX-010(AI 護欄 / MR review 檢查項)是同一套原則的兩層:

  • 寫程式、過 review → 以護欄(專案 CLAUDE.md 的「交互與體驗」段)為準,每條帶反例 + 兜底標註。
  • 理解為什麼、做設計決策 → 讀本文,展開的 why + 範例 + 業界理論都在這。

兩層互相連結、不互抄;護欄更新時本文手工同步。