小細節:Tooltip、捲軸、游標、間距
四個最常被跳過、但一眼就看得出有沒有做的地方。附上可以直接抄的 CSS 與判斷規則。
這四樣有個共同點:做了不會被稱讚,沒做會被一眼看穿。 它們也正好是 AI 生出來的介面最常整個略過的部分,因為它們不在任何 wireframe 上。
一、Tooltip
不要用 title=
瀏覽器原生的 title 屬性給你:約一秒不可調整的延遲、作業系統的字體、你控制不了的位置、完全無法設定樣式,以及在觸控裝置上什麼都沒有。
用 data-tip 加 CSS 偽元素,純 CSS 就能做完:
[data-tip] { position: relative; }
[data-tip]::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
translate: -50% 4px;
width: max-content;
max-width: 260px;
padding: var(--ds-space-2) var(--ds-space-3);
border-radius: var(--ds-tooltip-radius);
background: var(--ds-tooltip-bg);
color: var(--ds-tooltip-ink);
font-size: var(--ds-step--1);
opacity: 0;
pointer-events: none;
transition: opacity 120ms, translate 120ms;
}
[data-tip]:hover::after,
[data-tip]:focus-visible::after {
opacity: 1;
translate: -50% 0;
transition-delay: 320ms; /* 出現才延遲,消失要立刻 */
}
transition-delay 只加在顯示上:滑鼠掃過一排工具列不該一路閃出五個泡泡,但離開時應該立刻消失。
四條使用規則
- Tooltip 只能重複或縮短畫面上已經有的資訊。 任何完成任務必需的資訊都不能只放在裡面。
- 一定要
:focus-visible也會出現,否則鍵盤使用者永遠看不到。 - 觸控沒有 hover。 手機上 tooltip-only 的內容等於不存在。重要的話就寫在畫面上。
- 圖示按鈕的無障礙名稱來自
aria-label,tooltip 是額外的視覺提示,不是替代品。
<button aria-label="刪除" data-tip="刪除,無法復原">
<span aria-hidden="true">✕</span>
</button>
三個常見錯誤
- 停用的按鈕不會觸發 hover → tooltip 要包在外層的
<span>上 - 父層有
overflow: hidden→ tooltip 被切掉。這種情況要改用真的 popover - tooltip 裡放連結 → 滑鼠移過去的路上它就消失了。需要互動就是 popover,不是 tooltip
二、捲軸
為什麼要處理
不處理的話,Windows 上是一條又寬又灰、不屬於你任何色票的直條,而且吃掉約 15px 的寬度;macOS 上則是不動就看不見。同一個版面在兩個系統上看起來像兩個產品。
.ds-scroll {
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin; /* Firefox */
scrollbar-color: var(--ds-scrollbar-thumb) transparent; /* Firefox */
}
.ds-scroll::-webkit-scrollbar { width: 10px; height: 10px; }
.ds-scroll::-webkit-scrollbar-track { background: transparent; }
.ds-scroll::-webkit-scrollbar-thumb {
background: var(--ds-scrollbar-thumb);
border-radius: 999px;
/* 透明邊框 + background-clip 是唯一能讓 webkit 滑塊內縮的方法,
padding 在這裡完全沒有作用 */
border: 2px solid transparent;
background-clip: content-box;
}
四種處理,怎麼選
| 做法 | 什麼時候用 |
|---|---|
| 主題化(預設) | 大部分情況。看得見、跟著主題走 |
| hover 才出現 | 密集的後台,多個並排面板 |
| 完全隱藏 | 只在內容本身就能證明「還有更多」的地方 |
| 保留槽位 | 內容會從一頁變兩頁的頁面 |
隱藏捲軸的唯一合法情況
.ds-scroll[data-scrollbar='hidden'] {
scrollbar-width: none;
-ms-overflow-style: none;
}
.ds-scroll[data-scrollbar='hidden']::-webkit-scrollbar { display: none; }
只有當內容自己會說「還有更多」時才可以用。 橫向的篩選列可以,因為下一個被切一半的項目就是提示;輪播可以,因為有箭頭。
長文和不確定長度的清單絕對不行。 在桌機用滑鼠的人,捲軸是唯一知道「下面還有東西」的線索,藏掉它等於藏掉內容。
要藏就一定要補一個視覺訊號 —— 箭頭、露出半個的下一項,或邊緣淡出:
.scroll-fade::after {
content: '';
position: absolute;
inset-inline: 0;
bottom: 0;
height: 28px;
pointer-events: none;
background: linear-gradient(transparent, var(--ds-canvas));
}
兩個沒人記得但很有效的屬性
overscroll-behavior: contain; /* 捲到底不會帶著整頁一起動 */
scrollbar-gutter: stable; /* 內容變長時版面不會往左跳 15px */
第二個尤其明顯:頁面從一屏變兩屏時捲軸出現,整個版面往左位移 —— 這是很多人感覺「網站怪怪的」但說不出原因的來源。
三、游標
游標是最便宜的 affordance,也是最常做錯的。
三個經典錯誤
| 錯誤 | 後果 |
|---|---|
<div onclick> 保留文字 I 型游標 |
看起來不能點,一半的人不會去試 |
停用的按鈕還是 pointer |
看起來能點、點了沒反應 → 使用者認為壞掉了 |
| 送出中沒有任何游標變化 | 使用者再點一次 → 重複訂單 |
一次設定好
:where(button, summary, label[for], [role='button'], [role='tab'], a[href]) {
cursor: pointer;
}
:where(input:not([type='range']):not([type='checkbox']), textarea) {
cursor: text;
}
:where(:disabled, [aria-disabled='true']) { cursor: not-allowed; }
:where([aria-busy='true']) { cursor: progress; }
:where([aria-busy='true']) * { pointer-events: none; }
:where([draggable='true']) { cursor: grab; }
:where([draggable='true']):active { cursor: grabbing; }
倒數第二行很重要:aria-busy 同時做兩件事 —— 游標變成 progress,而且內部不再吃點擊。少了第二件,使用者會連按五次。
progress 和 wait 的差別
progress:在忙,但你還能操作別的地方。九成的情況用這個。wait:整個介面卡住。應該極少出現,出現了通常代表你該改成非同步。
拖曳的兩個細節
grab 按下去要變成 grabbing,否則拖曳過程感覺沒有回應。而且拖曳時要 user-select: none,不然會把旁邊的文字一起選起來。
拖曳也一定要有鍵盤替代路徑。分隔線給它 tabindex="0" 和方向鍵處理,只要幾行。
自訂游標
--ds-cursor-pointer: url("data:image/svg+xml;utf8,<svg …/></svg>") 3 2, pointer;
url() 後面那兩個數字是 hotspot(實際點擊的座標),寫錯的話每一次點擊都會偏幾個像素。
代價是真實的:失去作業系統的游標尺寸與高對比設定、主執行緒忙碌時會延遲、和使用者在其他地方看到的不一致。活動頁和形象站可以玩,每天要用八小時的工具不要。
四、間距
為什麼會亂
每個元素都自帶 margin → margin collapse 產生沒人預期的結果 → 有人用一個說不出理由的數字「修好」某一個間隙。半年後同一頁有十四種垂直間距。
三條規則
一、容器負責間距,元素不帶 margin。
.ds-flow > * + * { margin-block-start: var(--ds-flow-space, var(--ds-space-4)); }
只在兄弟之間加間距,第一個之前與最後一個之後永遠沒有多餘的空隙 —— 所以這個區塊丟到任何地方都不會多一截。
二、只用刻度上的值。 出現 13px 就代表有人在推像素而不是做決定。
三、空間屬於它所群組的東西。 標題屬於它下面的段落,不屬於上面的區塊,所以上方的空間要比下方大:
.ds-flow > :where(h2, h3, h4) { margin-block-start: var(--ds-space-8); }
.ds-flow > :where(h2, h3, h4) + * { margin-block-start: var(--ds-space-3); }
上下一樣大的話,讀者無法從版面看出哪一段屬於哪個標題 —— 這就是「一整頁看起來像一堵牆」的技術原因。
檢查方法
grep -rnE "(margin|padding|gap): *[0-9]+px" src/
只要出現不在刻度上的數字,就是有人在憑感覺推像素。
檢查清單
- 沒有用瀏覽器原生
title=當 tooltip - tooltip 在
:focus-visible也會出現 - 沒有任何必要資訊只存在於 tooltip 裡
- 所有捲動區域都有處理捲軸樣式
- 隱藏捲軸的地方都有另一個「還有更多」的訊號
- 捲動容器有
overscroll-behavior: contain - 可點的東西都是
pointer,停用的都是not-allowed - 處理中的狀態有
aria-busy且不再吃點擊 - 拖曳有
grab/grabbing切換,而且有鍵盤替代路徑 - 間距全部來自刻度,沒有裸數字
- 標題上方的空間大於下方