Skip to contentVibraUI
Utilities

Marquee

A strip that scrolls its children forever, and stops for hover, focus and reduced motion.

The children are rendered twice so the loop has something to come round to, and the second run is aria-hidden and inert — hidden from the reader and out of the tab order, because a duplicate link that can be tabbed to but not announced is worse than no duplicate at all. The travel is a speed rather than a duration: the track is measured with a ResizeObserver and moved by exactly one run plus the gap, so the seam is invisible and a long strip and a short one move at the same rate. The loop is a Web Animations API animation rather than a CSS keyframe, so the item installs into any project without a stylesheet of its own and pausing is animation.pause() rather than a class. Hover or focus anywhere inside parks it and data-paused says so; either request for less motion — the reader's own setting, or a [data-motion="reduced"] above the strip — means no loop is started at all, and data-reduced says that. It reports that state as data-reduced rather than as a data-motion of its own, because data-motion is the switch a page sets to *ask* for less motion and every useReducedMotion on the page is watching the document for one — a component that writes one is answering a question with the question, and wakes every other consumer on the page each time it renders. The strip duplicates its content exactly once, so it expects that content to already be at least as wide as the container it scrolls in; content narrower than that still loops, but shows a blank stretch each cycle where the second run has not caught up yet.

Install

npx shadcn@latest add @vibra/marquee

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
speednumber40Travel in pixels a second. The duration follows the measured content.
pauseOnHoverbooleantrueParks the loop under the pointer. Focus inside always parks it, either way.
direction"left" | "right""left"Which way the strip travels.
pausedbooleanfalseParks the loop from outside — a Pause button under the strip, the one way a keyboard or a touch has to stop it. Hover and focus still park a strip that is not paused this way; nothing they do starts one that is.

Dependencies

Source

components/ui/marquee.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useReducedMotion } from "@/hooks/use-reduced-motion"

// The gap between the two runs of the children, and therefore the distance the
// track has to travel past one run for the loop to close. Kept as a number
// because the animation is measured in pixels; `gap-8` below is the same value.
const GAP = 32

/** How far the strip travels in a second, in CSS pixels. Slow enough to read. */
const DEFAULT_SPEED = 40

export type MarqueeProps = React.ComponentProps<"div"> & {
  /** Travel in pixels a second. The loop's duration follows the content's width. */
  speed?: number
  pauseOnHover?: boolean
  direction?: "left" | "right"
  /**
   * Parks the loop from outside — a Pause button under the strip, which is the
   * one way a keyboard or a touch has to stop it. Hover and focus still park
   * a strip that is not paused this way; nothing they do starts one that is.
   */
  paused?: boolean
}

/**
 * A strip that scrolls its children forever.
 *
 * The children are rendered twice — the second run `aria-hidden`, so nothing is
 * announced or tabbed to twice — and the track is translated by exactly one run
 * plus the gap, which is what makes the seam invisible. The travel is a speed
 * rather than a duration: the duration is measured from the content, so a long
 * strip and a short one move at the same rate.
 *
 * It stops for the reader. Hover or focus anywhere inside it parks the loop
 * (`data-paused`), so does a `paused` prop — the page's own Pause button,
 * for a keyboard or a touch that cannot hover — and either request for less motion — the reader's own
 * setting, or a `[data-motion="reduced"]` above the strip — means no loop is
 * ever started at all (`data-reduced`). It reports that as `data-reduced` and
 * not as a `data-motion` of its own: `data-motion` is the switch a page sets to
 * *ask* for less motion, and every `useReducedMotion` on the page is watching
 * the document for it, so a component that writes one is answering a question
 * with the question.
 */
function Marquee({
  className,
  children,
  speed = DEFAULT_SPEED,
  pauseOnHover = true,
  direction = "left",
  paused: parked = false,
  onMouseEnter,
  onMouseLeave,
  onFocus,
  onBlur,
  ...props
}: MarqueeProps) {
  const root = React.useRef<HTMLDivElement | null>(null)
  const track = React.useRef<HTMLDivElement | null>(null)
  const run = React.useRef<HTMLDivElement | null>(null)
  const loop = React.useRef<Animation | null>(null)
  const [distance, setDistance] = React.useState(0)
  // The reader's own pause — a pointer or the keyboard inside the strip —
  // and the page's, from the prop. Either is enough to park it.
  const [held, setHeld] = React.useState(false)
  const paused = parked || held
  // Asked of the strip itself rather than the document, so a preview frame that
  // set `data-motion` on its own wrapper is heard.
  const reduced = useReducedMotion(root)

  // The animation effect below reads this rather than `paused` itself, so
  // that a resize mid-hover does not have to list `paused` as a dependency —
  // doing that would tear the whole animation down and rebuild it on every
  // hover and unhover, restarting its timeline instead of merely pausing it.
  // Ordered before that effect, so the two landing in the same commit still
  // leave this one read first.
  const pausedRef = React.useRef(paused)
  React.useEffect(() => {
    pausedRef.current = paused
  }, [paused])

  // One run of the children plus the gap after it: translate the track that far
  // and the second run lands exactly where the first one started.
  React.useEffect(() => {
    const node = run.current
    if (!node) return
    const read = () => setDistance(Math.round(node.getBoundingClientRect().width) + GAP)
    read()
    if (typeof ResizeObserver !== "function") return
    const observer = new ResizeObserver(read)
    observer.observe(node)
    return () => observer.disconnect()
    // Nothing: `children` is a fresh object on every render, and depending on
    // it would tear the observer down and build it again each time for a width
    // the observer is already watching.
  }, [])

  React.useEffect(() => {
    const node = track.current
    // Nothing measured yet, no motion wanted, or no animation API to drive it —
    // in all three the strip is simply a static row of its children.
    if (!node || reduced || distance <= 0 || speed <= 0) return
    if (typeof node.animate !== "function") return

    const from = direction === "left" ? 0 : -distance
    const to = direction === "left" ? -distance : 0
    const animation = node.animate(
      [{ transform: `translateX(${from}px)` }, { transform: `translateX(${to}px)` }],
      { duration: (distance / speed) * 1000, iterations: Infinity, easing: "linear" }
    )
    // A ResizeObserver update while parked lands here too — this effect
    // rebuilds the animation on every distance change, hover or not — and a
    // freshly created one otherwise plays from the first frame regardless of
    // what `data-paused` says.
    if (pausedRef.current) animation.pause()
    loop.current = animation
    return () => {
      animation.cancel()
      loop.current = null
    }
  }, [direction, distance, reduced, speed])

  // Declared after the effect that creates the animation, so the ref is already
  // filled on the render that starts one.
  React.useEffect(() => {
    if (paused) loop.current?.pause()
    else loop.current?.play()
  }, [paused])

  const park = (inside: boolean) => () => setHeld(inside)

  // Runs the caller's own handler first, then the strip's: `{...props}` below
  // still carries the rest of a native div's props, so a plain assignment
  // here would otherwise be replaced outright by a caller's own
  // onMouseEnter/onFocus/etc. rather than composed with it.
  const compose = <E,>(theirs: ((event: E) => void) | undefined, ours: (event: E) => void) => (event: E) => {
    theirs?.(event)
    ours(event)
  }

  return (
    <div
      ref={root}
      data-slot="marquee"
      data-direction={direction}
      data-paused={paused || undefined}
      data-reduced={reduced || undefined}
      className={cn("relative flex w-full overflow-hidden", className)}
      onMouseEnter={pauseOnHover ? compose(onMouseEnter, park(true)) : onMouseEnter}
      onMouseLeave={pauseOnHover ? compose(onMouseLeave, park(false)) : onMouseLeave}
      // focusin/focusout, so the keyboard landing anywhere inside parks it too.
      onFocus={compose(onFocus, park(true))}
      onBlur={compose(onBlur, park(false))}
      {...props}
    >
      <div ref={track} data-slot="marquee-track" className="flex w-max shrink-0 gap-8">
        <div ref={run} data-slot="marquee-content" className="flex shrink-0 items-center gap-8">
          {children}
        </div>
        {/* aria-hidden alone would leave a link or a button in the second run
            tabbable but unannounced, so the copy is inert as well as hidden. */}
        <div
          data-slot="marquee-content"
          aria-hidden="true"
          inert
          className="flex shrink-0 items-center gap-8"
        >
          {children}
        </div>
      </div>
    </div>
  )
}

export { Marquee }