mewo

Component

Marquee

An infinite horizontal scroller with a seamless loop. CSS only — no dependencies.

No dependencies
AcmeGlobexInitechUmbrellaHooliStark

Install

npx shadcn@latest add @mewo/marquee
Not using the registry directory?

Point the CLI straight at the item URL:

npx shadcn@latest add https://mewo-ui.vercel.app/r/marquee.json

Props

PropTypeDefaultDescription
childrenReactNodeThe items to scroll. Rendered `repeat` times for a seamless loop.
direction"left" | "right""left"Scroll direction.
speednumber30Seconds for one full loop.
pauseOnHoverbooleantruePause the animation while hovered.
repeatnumber4Copies of the children in the track. Raise it when one copy is much narrower than the container.
classNamestringMerged onto the root element.

Source

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface MarqueeProps extends React.ComponentProps<"div"> {
  direction?: "left" | "right"
  /** Seconds for one full loop. */
  speed?: number
  pauseOnHover?: boolean
  /** Copies of the children in the track. The loop is only seamless while (repeat - 1) copies cover the container. */
  repeat?: number
}

// Anything sequentially focusable by default, plus anything an author opted in with tabindex.
const FOCUSABLE =
  'a[href], area[href], button, input, select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])'

export function Marquee({
  children,
  direction = "left",
  speed = 30,
  pauseOnHover = true,
  repeat = 4,
  className,
  style,
  ...props
}: MarqueeProps) {
  // The duplicated tracks are what the loop paints for most of its cycle, so they have to stay
  // clickable and selectable — `inert` would take all of that away along with focus, retargeting
  // pointer events to the root and blocking text selection. aria-hidden keeps a duplicate out of
  // the accessibility tree; tabindex="-1" on its focusable descendants keeps it out of the tab
  // order, so focus never lands in a subtree that announces nothing (WCAG 4.1.2). A ref callback
  // declared here is a new function on every render, so React re-runs it whenever the children
  // change — and it runs during commit, before the first paint.
  const dropFromTabOrder = (track: HTMLDivElement | null) => {
    track?.querySelectorAll<HTMLElement>(FOCUSABLE).forEach((element) => {
      element.tabIndex = -1
    })
  }

  return (
    <div
      data-slot="marquee"
      className={cn("group flex overflow-hidden [--gap:1.5rem]", className)}
      style={{ "--marquee-duration": `${speed}s`, ...style } as React.CSSProperties}
      {...props}
    >
      {Array.from({ length: repeat }, (_, copy) => (
        <div
          key={copy}
          ref={copy > 0 ? dropFromTabOrder : undefined}
          data-marquee-copy={copy > 0 ? "duplicate" : "original"}
          aria-hidden={copy > 0 ? true : undefined}
          className={cn(
            "flex shrink-0 items-center gap-[var(--gap)] pr-[var(--gap)]",
            "motion-safe:animate-[mewo-marquee_var(--marquee-duration)_linear_infinite]",
            direction === "right" && "[animation-direction:reverse]",
            pauseOnHover && "group-hover:[animation-play-state:paused]"
          )}
        >
          {children}
        </div>
      ))}
    </div>
  )
}