"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 }