跳至正文
组件

Marquee跑马灯

纯 CSS 无缝跑马灯,支持四向滚动、恒定速度、渐隐与斜向丝带。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/marquee.json

用法

每组内容至少铺满容器,复制组首尾相接。动画由 CSS 驱动,ResizeObserver 仅测量一组内容的尺寸,按 尺寸 / speed 换算时长。

components/marquee-example.tsx
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),字形重叠处的内部轮廓会被盖住。

正在加载…

项目行情

用方向图标、正负号和数值表达变化。示例数据固定,仅展示布局,不连接实时服务。

正在加载…

技术栈

两行文字胶囊以相同速度反向移动。使用通用工具图标,不依赖品牌图形资源。

正在加载…

纵向动态

纵向滚动需要给容器设置明确高度,例如 className="h-56"。持续滚动的正文旁应提供暂停入口,示例里的按钮直接绑定 paused。

正在加载…

API

Marquee

  • direction 默认 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 组合带子和胶囊。

源码

components/ui/marquee/marquee.tsx
"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 }