主題: Web platform

原生 CSS Carousel 很香,但先別急著刪 JavaScript

CSS Overflow 5 能產生 carousel 按鈕與 markers,但仍非 Baseline。從 semantic list、scroll snap 與 progressive enhancement 開始,才能把新功能變成可靠介面。

動態迷因(展開/收合)
看到新 CSS controls 就把 fallback 拿掉?支援矩陣要先過關。 · 來源:GIPHY

Chrome 135 推出 ::scroll-button()::scroll-marker() 時,「carousel 終於不用 JavaScript」立刻成為很漂亮的標題。

這句話只對一半。

瀏覽器現在確實能替 scroll container 產生上一頁/下一頁按鈕、進度 markers、目前位置狀態與鍵盤 focus 行為。少掉手動同步 scrollLeft、disabled state 與 active dot 的程式碼,是實質進步。

MDN 目前仍把 scroll-marker-group 標成 limited availability 與 experimental。2026 年的 Reddit 討論也正好踩到另一條邊界:原生 controls 不會自動把最後一張無限接回第一張。

所以比較可靠的結論不是「刪掉 carousel library」,而是:

先把內容做成沒有新 selector 也能閱讀與水平捲動的 semantic list,再把 Overflow 5 controls 當 progressive enhancement。只有需求超過瀏覽器原生模型時,才留下最小 JavaScript。

Carousel 很擅長把六件東西塞進只看得到一件的空間,也很擅長讓後面五件沒人看到。

如果每一項都重要,responsive grid 通常更誠實。如果是文章步驟,普通 heading 與 document flow 更容易掃讀。如果只是因為首頁空間不夠,先減少內容,常比新增一套 navigation 更有效。

比較合理的使用情境是:

  • 同一組可獨立瀏覽、順序意義不強的卡片。
  • 小螢幕需要保留較大的 item width。
  • 內容即使只能自然捲動,仍然完整可用。

這個判斷要在研究 selector 以前完成。否則最精巧的 CSS 仍然只是在替錯的 information architecture 擦亮箭頭。

Baseline 應該是一個普通 list

Chrome 的 accessibility guidance建議:內容語意上是清單,就用 <ul>。這不是裝飾性選擇;即使瀏覽器不認得新 pseudo-elements,screen reader 與無 CSS 環境仍知道這是一組相關項目。

<section aria-labelledby="featured-title">
  <h2 id="featured-title">Featured articles</h2>
  <ul class="carousel">
    <li data-label="文章 1">...</li>
    <li data-label="文章 2">...</li>
    <li data-label="文章 3">...</li>
  </ul>
</section>

第一層 CSS 只使用成熟的 overflow 與 scroll snap

.carousel {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  overscroll-behavior-inline: contain;
  scroll-snap-type: x mandatory;
}

.carousel > li {
  flex: 0 0 min(85%, 24rem);
  scroll-snap-align: start;
}

支援 touch、trackpad、mouse wheel 或 scrollbar 的使用者現在都能抵達每一項。新功能完全失效時,產品仍然成立。這才叫 fallback;不是顯示一張「請改用 Chrome」的告示。

mandatory 也不是固定答案。Item 高度可能超過 scrollport 時,強制 snap 會讓內容中段難以停留;那種情況應改用 proximity 或重新檢查 layout。先測內容,再選值。

再讓瀏覽器產生 controls

CSS Overflow Module Level 5定義的 ::scroll-button() 會以「page」為單位移動 scroller,並在不能再往該方向捲動時進入 disabled state。::scroll-marker 對應各 item,而 :target-current 表示目前 active marker。

不支援這些 selectors 的瀏覽器會忽略以下獨立規則,baseline 不受影響:

.carousel::scroll-button(left) {
  content: "←" / "Previous items";
}

.carousel::scroll-button(right) {
  content: "→" / "Next items";
}

.carousel {
  scroll-marker-group: after;
}

.carousel > li::scroll-marker {
  content: attr(data-label);
}

.carousel > li::scroll-marker:target-current {
  background: currentColor;
}

生成按鈕的 content 不只是箭頭。斜線後面的文字提供 accessible name;只寫 content: "→",視覺上有按鈕,不代表 assistive technology 聽得懂它。

Markers 也不能全部叫空字串。MDN 特別提醒 content: "" 會留下沒有名稱的 control。data-label 可以提供「Article 2」之類的名稱,但自動翻譯工具不一定會處理 attribute;多語站要自己維護本地化值。

新 CSS 與少量 JavaScript,可以同時成立

目前的重點限制很簡單:這批功能還不是 Baseline。若產品支援矩陣包含尚未實作它們的瀏覽器,不能把生成 controls 當唯一入口。

動態迷因(展開/收合)
Carousel 不需要變成 CSS 與 JavaScript 的監護權之爭:先保留能跑的 baseline,再讓支援的瀏覽器加上新 controls。 · 來源:GIPHY

這時有三個合理層級:

需求 最小方案
可水平瀏覽的 cards Semantic list、overflow、scroll snap
支援瀏覽器中的 arrows 與 dots ::scroll-button()::scroll-marker()
全瀏覽器一致 controls 保留真實 <button> 與少量 JS,或使用已驗證 component

第三層不是失敗。Progressive enhancement 的目的不是把 JavaScript 數量壓到零,而是讓每一層失效時都還有下一層可以站。

如果同一頁已經有成熟、可存取且體積合理的 carousel component,也別只為追新 selector 重寫。等支援矩陣與維護成本真的改變,再刪程式碼;不是看到 demo 就先製造 migration。

哪些需求仍然超出原生模型?

原生 scroll controls 很適合有限、手動導覽的內容。下列功能則仍需要額外 state 或行為:

  • 無限循環:最後一項接回第一項通常需要複製 DOM、重設位置或重新定義產品需求。
  • 自動播放:需要 pause/resume、focus 與 hover 行為、reduced motion,以及清楚的 rotation control。
  • 遠端分頁:接近尾端載入資料、處理 retry 與保留位置仍是 application logic。
  • 精確 analytics:曝光、可見比例與 interaction attribution 不會因為 active marker 出現就自動完成。
  • 複雜同步:縮圖、媒體播放、URL state 或多個 scrollers 連動,仍需要明確 state model。

尤其不要用 CSS animation 偷渡 autoplay,然後宣稱「零 JavaScript」。WAI carousel pattern要求自動輪播有停止/重新開始 control,focus 進入時停止,而且不能擅自恢復。這些是互動需求,不是 bundle size 的敵人。

如果需求只是「展示四張卡片」,最好完全不做 autoplay。刪掉移動通常比替移動補十條 accessibility rules 更便宜。

Accessibility 不能外包給 pseudo-element

瀏覽器替 controls 提供 semantics 與 focus behavior,確實少掉不少容易寫錯的 code。但 Chrome 官方文件也明說:沒有任何 UI 能保證 out of the box accessible,仍要實際測試。

至少確認:

  1. Carousel 有可理解的 visible heading 或 accessible label。
  2. Tab order 與 controls 的視覺順序一致;scroll-marker-group: before/after 要跟 layout 對齊。
  3. 每個 button 與 marker 有唯一、可本地化的名稱。
  4. Focus ring 清楚,disabled state 不只靠低對比。
  5. Off-screen interactive content 不會讓 keyboard user 在看不見的 links 間旅行。
  6. Zoom、RTL、touch、keyboard、screen reader 與 reduced motion 都有測試。

單項 carousel 可用 scroll-state query 搭配 interactivity: inert,讓非 active slide 的內容退出 focus flow;多項同時可見的 carousel 則不該把所有其他 visible items 變 inert。控制 focus 的規則要跟視覺模型一致,不能從 demo 整段複製。

實際採用順序

  1. 先證明 carousel 比 grid 或普通 flow 更適合內容。
  2. 用 semantic HTML 排出沒有 JavaScript 也能閱讀的版本。
  3. 加 overflow 與 scroll snap,測 touch、keyboard 與長內容。
  4. 把 Overflow 5 rules 放在獨立 selectors 中,讓舊瀏覽器安全忽略。
  5. 依正式 browser support matrix 做實機與 assistive technology 測試。
  6. 只有無限循環、autoplay、資料載入或一致 controls 真有需求時,才加最小 JS。
  7. 用使用數據確認 carousel 內後段內容真的有人看到;沒有,就換回 grid。

結論:原生功能是刪 code 的機會,不是重寫的命令

::scroll-button()::scroll-marker() 最有價值的地方,不是讓開發者在社群上貼出「0 KB JavaScript」。它們把 scrolling、disabled state、active marker 與部分 keyboard behavior 交回瀏覽器,減少每個團隊重造同一套脆弱 state machine。

但可靠導入順序依然很無聊:semantic content、可用 baseline、原生 enhancement、最後才是必要的 JavaScript。

新 CSS 很強;最好的使用方式,是讓不支援它的瀏覽器也不需要知道自己錯過了什麼。


外部參考資料