import { cn } from "@/lib/utils"
import { Marquee, MarqueeItem } from "@/components/ui/marquee/marquee"
const WORDS = ["线条网格", "LINE GRID", "组件库", "开源", "BUILD WITH CARE"]
// 两条丝带反向滚动,旋转后在中间交叉;开启「减少动态效果」时回到水平,所以各自错开位置
const RIBBONS = [
{
rotate: -9,
direction: "left",
className: "bg-foreground text-background motion-reduce:top-1/3",
},
{
rotate: 9,
direction: "right",
className:
"border-y border-edge bg-muted text-foreground motion-reduce:top-2/3",
},
] as const
export default function MarqueeDemo() {
return (
<div className="relative h-56 w-full overflow-hidden bg-dots">
{RIBBONS.map(({ rotate, direction, className }) => (
<Marquee
key={rotate}
label="线条网格与组件库"
rotate={rotate}
direction={direction}
speed={42}
fade={false}
className={cn(
"absolute top-1/2 -left-[10%] w-[120%] -translate-y-1/2 py-4 motion-reduce:left-0 motion-reduce:w-full",
className,
)}
>
{WORDS.map((word) => (
<MarqueeItem
key={word}
className="gap-8 text-lg font-semibold tracking-tight sm:text-2xl"
>
{word}
<span aria-hidden className="text-sm opacity-50">
✳
</span>
</MarqueeItem>
))}
</Marquee>
))}
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/marquee.json用法
每组内容至少铺满容器,复制组首尾相接。动画由 CSS 驱动,ResizeObserver 仅测量一组内容的尺寸,按 尺寸 / speed 换算时长。
import { Marquee, MarqueeItem } from "@/components/ui/marquee/marquee"
export function MarqueeExample() {
return (
<Marquee speed={48} gap={32} label="设计关键词">
<MarqueeItem>线条网格</MarqueeItem>
<MarqueeItem>LINE GRID</MarqueeItem>
<MarqueeItem>组件库</MarqueeItem>
</Marquee>
)
}页首的预览是两条反向滚动的丝带:rotate 把整条带子旋转 ±9°,交叉成 X 形。旋转带应放在带 overflow-hidden 的外层,并加宽到覆盖两端。
示例
每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整速度、间距、旋转,并开关反向、两端渐隐、悬停暂停与暂停;未改动的项显示示例自己的取值。调整时预览画框大小不变。
实心与描边
同一条带里交替使用实心字与描边字,两行反向滚动。描边字用背景色填充,并先画描边(paint-order: stroke fill),字形重叠处的内部轮廓会被盖住。
import { Marquee, MarqueeItem } from "@/components/ui/marquee/marquee"
const WORDS = ["简洁", "CLARITY", "克制", "LESS", "精确", "PRECISION"]
// 描边字:用背景色填充并先画描边,字形重叠处的内部轮廓会被盖住
const OUTLINE =
"text-background [-webkit-text-stroke:1.5px_var(--foreground)] [paint-order:stroke_fill]"
export default function MarqueeTypeDemo() {
return (
<div className="flex w-full min-w-0 flex-col gap-3 py-6">
{[false, true].map((reverse) => (
<Marquee
key={String(reverse)}
reverse={reverse}
speed={38}
gap={28}
label="简洁、克制、精确"
>
{WORDS.map((word, index) => (
<MarqueeItem
key={word}
className="gap-7 text-5xl font-semibold tracking-tight sm:text-6xl"
>
<span className={index % 2 ? OUTLINE : undefined}>{word}</span>
<span aria-hidden className="text-xl text-primary">
✦
</span>
</MarqueeItem>
))}
</Marquee>
))}
</div>
)
}
项目行情
用方向图标、正负号和数值表达变化。示例数据固定,仅展示布局,不连接实时服务。
import { ArrowDownRight, ArrowUpRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Marquee, MarqueeItem } from "@/components/ui/marquee/marquee"
// 固定的演示数据,不连接任何实时服务
const STATS = [
{ label: "组件安装", value: "12,840", change: "+12.4%", up: true },
{ label: "Star", value: "2,048", change: "+8.2%", up: true },
{ label: "周下载", value: "36,210", change: "+16.8%", up: true },
{ label: "构建耗时", value: "24.6s", change: "−3.2%", up: false },
]
export default function MarqueeTickerDemo() {
return (
<Marquee speed={32} gap={12} label="组件安装、Star、下载与构建耗时">
{STATS.map(({ label, value, change, up }) => (
<MarqueeItem
key={label}
className="rounded-full border border-edge bg-card px-4 py-2.5 text-xs"
>
<span className="text-muted-foreground">{label}</span>
<span className="font-mono font-semibold">{value}</span>
<span
className={cn(
"flex items-center gap-1 font-mono",
up ? "text-primary" : "text-muted-foreground",
)}
>
{up ? (
<ArrowUpRight className="size-3.5" aria-hidden />
) : (
<ArrowDownRight className="size-3.5" aria-hidden />
)}
{change}
</span>
</MarqueeItem>
))}
</Marquee>
)
}
技术栈
两行文字胶囊以相同速度反向移动。使用通用工具图标,不依赖品牌图形资源。
import { Braces, Code, Database, Layers, Palette, Terminal } from "lucide-react"
import { Marquee, MarqueeItem } from "@/components/ui/marquee/marquee"
const TOOLS = [
{ name: "Next.js", icon: Layers },
{ name: "TypeScript", icon: Braces },
{ name: "Tailwind CSS", icon: Palette },
{ name: "React", icon: Code },
{ name: "Node.js", icon: Terminal },
{ name: "PostgreSQL", icon: Database },
]
export default function MarqueeStackDemo() {
return (
<div className="flex w-full min-w-0 flex-col gap-3">
{/* 第二行倒序并反向滚动,两行的内容在画面里错开 */}
{[false, true].map((reverse) => (
<Marquee
key={String(reverse)}
reverse={reverse}
speed={26}
gap={12}
label="技术栈工具列表"
>
{(reverse ? TOOLS.toReversed() : TOOLS).map(
({ name, icon: Icon }) => (
<MarqueeItem
key={name}
className="rounded-lg border border-edge bg-card px-4 py-3 text-sm"
>
<Icon className="size-4 text-primary" aria-hidden />
{name}
</MarqueeItem>
),
)}
</Marquee>
))}
</div>
)
}
纵向动态
纵向滚动需要给容器设置明确高度,例如 className="h-56"。持续滚动的正文旁应提供暂停入口,示例里的按钮直接绑定 paused。
"use client"
import * as React from "react"
import { Pause, Play } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Marquee, MarqueeItem } from "@/components/ui/marquee/marquee"
const UPDATES = [
{ time: "10:42", title: "组件已发布", note: "新增四向滚动与交叉丝带。" },
{ time: "10:28", title: "构建完成", note: "全部页面已导出为静态文件。" },
{ time: "09:56", title: "文字动效就绪", note: "支持中文分字与乱码解码。" },
{ time: "09:30", title: "主题同步", note: "亮暗模式与颜色主题独立切换。" },
]
export default function MarqueeVerticalDemo() {
// 持续滚动的正文应当提供暂停入口,直接绑定 `paused`
const [paused, setPaused] = React.useState(false)
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">更新动态</p>
<Button
variant="outline"
size="sm"
aria-pressed={paused}
onClick={() => setPaused((value) => !value)}
>
{paused ? <Play /> : <Pause />}
暂停滚动
</Button>
</div>
<Marquee
direction="up"
speed={22}
gap={16}
paused={paused}
className="h-56"
label="组件库更新动态"
>
{UPDATES.map(({ time, title, note }) => (
<MarqueeItem
key={time}
className="w-full items-start gap-4 border-b border-edge pb-4 whitespace-normal"
>
<span className="pt-1 font-mono text-[10px] text-muted-foreground">
{time}
</span>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
<span className="text-xs leading-5 text-muted-foreground">
{note}
</span>
</div>
</MarqueeItem>
))}
</Marquee>
</div>
)
}
API
Marquee
MarqueeDirection | undefined每秒移动的像素数;脚本只测量,不驱动动画。
类型number | undefinedboolean | undefinedboolean | undefinedboolean | undefined总内容组数,至少 2 组。
类型number | undefinedboolean | undefined项目之间的像素间距。
类型number | undefined整条带子的旋转角度。
类型number | undefinedstring | undefineddirection默认left,支持left / right / up / down;reverse在当前方向上反转。speed默认48,单位为像素/秒,最小按1处理。间距与字体变化都会重新测量,因此长短内容的移动速度一致。repeat默认3,表示内容组总数,限制在2–20。它不改变一组内容的移动速度。gap默认32,单位为像素;负值按0处理。组尾保留相同间距,接缝不会多一格或少一格。fade默认开启,沿滚动方向对两端做渐隐。rotate默认0,单位为度。旋转带应放在带overflow-hidden的外层,并适当加宽以覆盖两端。pauseOnHover默认开启;paused可直接暂停。键盘焦点进入容器时始终暂停。label默认“跑马灯”,可覆盖区域的朗读说明。
MarqueeItem
MarqueeItem 没有自定义 Props;该组件继承原生元素的全部属性。
项目默认不收缩、不换行,支持自定义布局和样式。纵向多行内容可用 whitespace-normal。
调整 Context
MarqueeTuningContext 接收 MarqueeTuning 覆盖项;onReport(report: MarqueeReport) 回报覆盖前的实际属性。
可访问性
- 只有第一组内容参与朗读;复制组使用
aria-hidden与inert,不进入焦点顺序。 - 区域可通过 Tab 聚焦,聚焦后暂停,便于阅读。
- 开启“减少动态效果”后取消动画、旋转与遮罩,只保留原始内容;可用键盘或触控滚动查看,纵向列表同样可滚动。
- 对会持续移动的正文,提供邻近的暂停控件并绑定
paused。装饰性丝带可在外层使用aria-hidden,同时设置tabIndex={-1}。
注意事项
-
调整面板通过
MarqueeTuningContext覆盖子树里的speed、gap、rotate、reverse、fade、pauseOnHover、paused,并用onReport回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。 -
无 JS 时仍运行 CSS 动画,使用每组
30s的回退时长。精确的 px/s 换算需要客户端测量,不能从服务端获知任意字体与内容的实际宽度。 -
请使用非交互展示内容,避免在复制组内放置表单、带固定
id的元素或有副作用的组件。复制体虽然不可交互,仍会挂载。 -
组件自身不设置色彩或背景;通过主题 token 组合带子和胶囊。
源码
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const MARQUEE_CSS = `
@keyframes tv-marquee-x { to { transform: translateX(-100%) } }
@keyframes tv-marquee-y { to { transform: translateY(-100%) } }
[data-slot="marquee"] { display:flex; overflow:hidden; container-type:inline-size; rotate:var(--marquee-rotate) }
[data-slot="marquee-group"] { display:flex; flex:none; align-items:center; justify-content:space-around; gap:var(--marquee-gap); min-width:100%; padding-inline-end:var(--marquee-gap); animation:tv-marquee-x var(--marquee-duration,30s) linear infinite; animation-direction:var(--marquee-reverse) }
[data-slot="marquee"][data-vertical="true"] { flex-direction:column }
[data-vertical="true"] > [data-slot="marquee-group"] { flex-direction:column; min-width:0; width:100%; min-height:100%; padding-inline-end:0; padding-block-end:var(--marquee-gap); animation-name:tv-marquee-y }
[data-slot="marquee"][data-fade="true"] { mask-image:linear-gradient(to right,transparent,var(--foreground) 8%,var(--foreground) 92%,transparent) }
[data-slot="marquee"][data-vertical="true"][data-fade="true"] { mask-image:linear-gradient(to bottom,transparent,var(--foreground) 8%,var(--foreground) 92%,transparent) }
[data-slot="marquee"]:focus-within > [data-slot="marquee-group"], [data-slot="marquee"][data-paused="true"] > [data-slot="marquee-group"], [data-slot="marquee"][data-hover="true"]:hover > [data-slot="marquee-group"] { animation-play-state:paused }
@media(prefers-reduced-motion:reduce) {
[data-slot="marquee"] { overflow:auto; mask-image:none!important; rotate:none }
[data-slot="marquee-group"] { animation:none!important; justify-content:flex-start }
[data-slot="marquee-group"][aria-hidden="true"] { display:none }
}`
/** 文档调整覆盖项;不提供 Provider 时沿用组件属性。 */
type MarqueeReport = {
speed: number
reverse: boolean
gap: number
rotate: number
fade: boolean
pauseOnHover: boolean
paused: boolean
}
type MarqueeTuning = Partial<MarqueeReport> & {
/** 回报覆盖前的示例取值。 */
onReport?: (report: MarqueeReport) => void
}
const MarqueeTuningContext = React.createContext<MarqueeTuning>({})
type MarqueeDirection = "left" | "right" | "up" | "down"
type MarqueeProps = React.ComponentProps<"div"> & {
direction?: MarqueeDirection
/** 每秒移动的像素数;脚本只测量,不驱动动画。 */
speed?: number
pauseOnHover?: boolean
paused?: boolean
reverse?: boolean
/** 总内容组数,至少 2 组。 */
repeat?: number
fade?: boolean
/** 项目之间的像素间距。 */
gap?: number
/** 整条带子的旋转角度。 */
rotate?: number
label?: string
}
function Marquee({
direction = "left",
speed: speedProp = 48,
pauseOnHover: pauseOnHoverProp = true,
paused: pausedProp = false,
reverse: reverseProp = false,
repeat = 3,
fade: fadeProp = true,
gap: gapProp = 32,
rotate: rotateProp = 0,
label = "跑马灯",
children,
className,
style,
...props
}: MarqueeProps) {
const tuning = React.useContext(MarqueeTuningContext)
const { onReport } = tuning
const speed = tuning.speed ?? speedProp
const reverse = tuning.reverse ?? reverseProp
const gap = tuning.gap ?? gapProp
const rotate = tuning.rotate ?? rotateProp
const fade = tuning.fade ?? fadeProp
const pauseOnHover = tuning.pauseOnHover ?? pauseOnHoverProp
const paused = tuning.paused ?? pausedProp
React.useEffect(() => {
onReport?.({
speed: speedProp,
reverse: reverseProp,
gap: gapProp,
rotate: rotateProp,
fade: fadeProp,
pauseOnHover: pauseOnHoverProp,
paused: pausedProp,
})
}, [
onReport,
speedProp,
reverseProp,
gapProp,
rotateProp,
fadeProp,
pauseOnHoverProp,
pausedProp,
])
const root = React.useRef<HTMLDivElement>(null)
const group = React.useRef<HTMLDivElement>(null)
const vertical = direction === "up" || direction === "down"
const backwards = (direction === "right" || direction === "down") !== reverse
const count = Math.min(20, Math.max(2, Math.floor(repeat) || 2))
React.useEffect(() => {
const element = group.current
const viewport = root.current
if (!element || !viewport) return
function measure() {
if (!element || !viewport) return
const distance = vertical ? element.offsetHeight : element.offsetWidth
viewport.style.setProperty(
"--marquee-duration",
`${distance / Math.max(1, speed)}s`,
)
}
const observer = new ResizeObserver(measure)
observer.observe(element)
observer.observe(viewport)
measure()
return () => observer.disconnect()
}, [speed, vertical])
return (
<div
{...props}
ref={root}
data-slot="marquee"
data-vertical={vertical}
data-fade={fade}
data-hover={pauseOnHover}
data-paused={paused}
role="region"
aria-label={label}
tabIndex={0}
className={cn(
"relative w-full min-w-0 outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
className,
)}
style={
{
"--marquee-gap": `${Math.max(0, gap)}px`,
"--marquee-rotate": `${rotate}deg`,
"--marquee-reverse": backwards ? "reverse" : "normal",
...style,
} as React.CSSProperties
}
>
<style href="travinsy-marquee" precedence="default">
{MARQUEE_CSS}
</style>
{Array.from({ length: count }, (_, index) => (
<div
key={index}
ref={index === 0 ? group : undefined}
data-slot="marquee-group"
aria-hidden={index > 0 ? true : undefined}
inert={index > 0 ? true : undefined}
>
{children}
</div>
))}
</div>
)
}
function MarqueeItem({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="marquee-item"
className={cn(
"flex shrink-0 items-center gap-3 whitespace-nowrap",
className,
)}
{...props}
/>
)
}
export { MarqueeTuningContext, Marquee, MarqueeItem }
export type { MarqueeTuning, MarqueeReport, MarqueeProps, MarqueeDirection }