網頁跑馬燈最常見的用途是客戶 logo 牆、評價牆與公告列。做法本身不複雜,但有幾個細節會決定它是否順暢、是否能被鍵盤與螢幕閱讀器使用者正常操作。這篇以 Tailwind CSS v4.3 與 React 19 為準,從 CSS 原理寫到完整元件,最後介紹 WebberUI 裡現成的跑馬燈元件。
原理:兩份內容,位移 -50%
無限循環的關鍵是把同一份內容放兩次,排成一條比容器寬的軌道,再把整條軌道從 translateX(0) 移到 translateX(-50%)。移到終點時,第二份內容剛好站在第一份的起點,動畫重新開始時畫面不會跳動。
要讓 -50% 精準對齊,兩份內容的寬度必須完全相同,包含尾端間距。如果用 gap-8 排列項目,每一份的最後也要補上同樣的 pr-8,否則接縫處會少一段間距。
第一步:在 @theme 定義動畫
Tailwind v4 改用 CSS 設定。在全域 CSS 加入:
@import "tailwindcss";
@theme inline {
--animate-marquee: marquee var(--marquee-duration, 30s) linear infinite;
@keyframes marquee {
to {
transform: translateX(-50%);
}
}
}這樣就有 animate-marquee 這個 utility,keyframes 也只會在用到時輸出。
這裡用 @theme inline 有原因。一般的 @theme 會把值寫成 :root 上的 CSS 變數,var(--marquee-duration) 會在 :root 就被解析成預設的 30s,之後在元素上設定 --marquee-duration 不會生效。加上 inline 後,utility 會直接輸出 animation: marquee var(--marquee-duration, 30s) linear infinite,變數改在元素本身解析,每個跑馬燈就能各自設定速度。詳見 Tailwind 的 theme 文件。
第二步:React 元件
"use client";
import { useState, type CSSProperties, type ReactNode } from "react";
type MarqueeProps = {
/** 一組 <li>,會被渲染兩次 */
children: ReactNode;
/** 給螢幕閱讀器的區塊名稱,例如「合作客戶」 */
label: string;
reverse?: boolean;
/** 跑完一份內容的時間 */
duration?: string;
};
export function Marquee({
children,
label,
reverse = false,
duration = "30s",
}: MarqueeProps) {
const [paused, setPaused] = useState(false);
return (
<section aria-label={label} className="flex items-center gap-3">
<div
data-paused={paused}
style={{ "--marquee-duration": duration } as CSSProperties}
className="group flex-1 overflow-hidden mask-x-from-90% motion-reduce:overflow-x-auto motion-reduce:mask-none"
>
<div
className={[
"flex w-max animate-marquee motion-reduce:animate-none",
"group-hover:[animation-play-state:paused]",
"group-focus-within:[animation-play-state:paused]",
"group-data-[paused=true]:[animation-play-state:paused]",
reverse ? "[animation-direction:reverse]" : "",
].join(" ")}
>
<ul className="flex shrink-0 items-center gap-8 pr-8">{children}</ul>
<ul
aria-hidden="true"
inert
className="flex shrink-0 items-center gap-8 pr-8 motion-reduce:hidden"
>
{children}
</ul>
</div>
</div>
<button
type="button"
onClick={() => setPaused((p) => !p)}
className="shrink-0 rounded-md border px-3 py-1.5 text-sm motion-reduce:hidden"
>
{paused ? "播放" : "暫停"}
</button>
</section>
);
}使用方式:
<Marquee label="合作客戶" duration="40s">
{logos.map((logo) => (
<li key={logo.name}>
<img src={logo.src} alt={logo.name} className="h-8 w-auto" />
</li>
))}
</Marquee>下面逐項說明每個 class 的作用。
懸停與聚焦時暫停
Tailwind v4 沒有 animation-play-state 的內建 utility,用任意屬性寫成 [animation-play-state:paused],再搭配 group-hover:。要注意 v4 的 hover: 與 group-hover: 會包在 @media (hover: hover) 裡,手機觸控不會觸發。這是刻意的設計,所以觸控裝置需要另外的暫停方式,也就是上面那顆按鈕。
group-focus-within: 讓鍵盤使用者 Tab 進入區塊內的連結時停住,否則焦點框會跟著內容跑走。
反向
[animation-direction:reverse] 讓同一組 keyframes 倒著播。評價牆常見的「相鄰兩列反向」就是兩個 Marquee,其中一個加 reverse。
邊緣淡出
mask-x-from-90% 是 Tailwind v4.1 起內建的 mask utility,左右各留 10% 漸隱。如果專案還在 v4.0,可改用任意值:
<div class="[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]"></div>複製的那一份要對輔助科技隱藏
第二份 <ul> 只是視覺用途。加上 aria-hidden="true",螢幕閱讀器就不會把每個 logo 讀兩次;加上 inert(React 19 已支援這個屬性)則讓裡面的連結不會進入 Tab 順序。只加 aria-hidden 而內容有連結時,鍵盤仍然能聚焦到一個被宣告為隱藏的元素,這是常見的無障礙錯誤。
prefers-reduced-motion
系統設定「減少動態效果」時,motion-reduce: 前綴會:
- 停掉動畫(
animate-none) - 隱藏複製的那一份與暫停按鈕
- 移除遮罩並改成可水平捲動(
overflow-x-auto),使用者仍看得到全部內容
只停動畫而不處理溢出,後半段的 logo 會被裁掉、永遠看不到。
為什麼需要暫停按鈕
WCAG 2.2 的 2.2.2 Pause, Stop, Hide 要求自動移動且持續超過五秒的內容,要提供暫停、停止或隱藏的方法。懸停暫停對滑鼠有效,觸控與鍵盤使用者需要一個明確的控制項。按鈕切換的是外層的 data-paused,CSS 用 group-data-[paused=true]: 接手,不必在 JavaScript 裡操作動畫。
速度與內容寬度
duration 是「跑完一份內容的時間」,所以內容越寬,畫面上看起來移動越快。如果同一頁有多條跑馬燈,希望它們速度一致,可以依內容寬度換算:用 ResizeObserver 量出第一個 <ul> 的寬度,除以每秒想要的像素數,再寫回 --marquee-duration。
垂直跑馬燈的做法相同,把 keyframes 換成 translateY(-50%)、軌道改成 flex-col h-max,外層容器給固定高度。
為什麼不用 <marquee> 標籤
HTML 的 <marquee> 元素在 MDN 上標示為已棄用,規範只為了相容舊網頁而保留它。它無法調整速度曲線、不會尊重 prefers-reduced-motion,也沒有暫停的標準介面。用 CSS 動畫做,速度、方向、暫停與無障礙行為都在你的程式碼裡控制。
WebberUI 的跑馬燈元件
如果需要的不只是等速循環,WebberUI 有幾個已處理好上述細節的元件,都用 WebberUI CLI 安裝、原始碼放進你的 components/ui/:
- Velocity Marquee:速度跟著頁面捲動速度變化,快速往回捲時短暫反向。用
motion/react的useAnimationFrame逐幀計算位移,減少動態效果時改為靜態排列。免費。 - Testimonial Wall:三欄反向捲動的評價牆,有自動捲動與捲動視差兩種樣式,內建
pauseOnHover。免費。 - 3d Marquee:傾斜的 3D 網格,多欄交錯上下捲動,適合作品集或產品截圖牆。Pro。
- Team + Logo Wall:團隊成員網格加上客戶 logo 跑馬牆,可直接當關於頁的區塊。Pro。
把這個效果裝進你的專案
在專案資料夾執行指令,CLI 會加入原始碼與依賴套件,再依下方使用範例引用元件。
npx webberui@latest add velocity-marquee安裝免費的 Velocity Marquee:
npx webberui@latest add https://webberui.com/r/velocity-marquee.json