Marquee

Auto-scrolling row that is also a real horizontal scroller, endless in both directions.

Status
stable
Tags
  • motion
  • layout

Setup

  1. Load src/styles/marquee.css in the head.
  2. Author one track inside <div data-marquee role="group" aria-label="...">.
  3. Call initMarquees() from src/scripts/marquee.ts once the page has loaded. It sets up every [data-marquee], and returns a clean-up function.

The row sits still until the script adds copies of the track.

Wraps the HTML core, so the logic is shared.

Setup

The component imports the CSS and the script. It renders copies on the server (copies prop), so the row loops before JavaScript runs.

Parity with the default: full

Install

Expects in your project: react >=19.

Setup

A native React version: it shares marquee.css with the other versions, and not their script. It renders on the server with the copies prop, and adds more copies in the browser on wide screens.

Copies set up their links when they mount. If the items change, give the component a new key.

Demo

Files

/**
* Marquee for React: a native version, not a wrapper around the HTML script.
* It shares `marquee.css` with the other versions, so it looks and moves the
* same way.
*
* <Marquee label="Partners" duration="40s">
* <li>One</li>
* <li>Two</li>
* </Marquee>
*
* How it stays fast: CSS runs the animation, so React never renders per frame.
* State only changes when the row pauses, resumes, goes off screen or needs
* more copies. Refs handle the scroll position.
*
* Copies take the tab-key changes they need when they first mount. If the
* items change, give the component a new `key`, so every copy starts fresh.
*/
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ElementType,
type ReactNode,
type RefObject,
} from "react";
import "../styles/marquee.css";
const RESUME_AFTER_SCROLLEND = 600;
const RESUME_FALLBACK = 2000;
const MAX_TRACKS = 24;
const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]";
interface MarqueeProps {
/** Element used for the track. Children are its items. */
as?: "ul" | "ol" | "div";
/** One loop of the animation, such as `60s`. A bare number is seconds. */
duration?: string | number;
/** Space between items and between copies of the track. */
gap?: string;
direction?: "forward" | "reverse";
/** Pause the animation on hover, on pointer devices only. */
pauseOnHover?: boolean;
/** Copies of the track to render at first, on the server too. More are added on wide screens. */
copies?: number;
/** Names the scrollable region for screen readers and keyboard users. */
label?: string;
children: ReactNode;
}
/* ---- Helpers that work on the DOM, outside React's render ---- */
/** From the start of one copy to the start of the next. */
function periodOf(root: HTMLElement, track: HTMLElement) {
const gap = parseFloat(getComputedStyle(root).columnGap) || 0;
return track.getBoundingClientRect().width + gap;
}
/** Moves the scroll position without it counting as the user scrolling. */
function setScrollLeft(root: HTMLElement, selfScrolling: RefObject<boolean>, value: number) {
selfScrolling.current = true;
root.scrollLeft = value;
requestAnimationFrame(() => (selfScrolling.current = false));
}
/**
* Runs when a copy mounts. Copies are hidden from screen readers by
* `aria-hidden`, and this takes their links out of the tab order. It also
* starts the copy's animation in step with the others, so the loop has no seam.
*/
function prepareCopy(copy: HTMLElement | null) {
if (!copy) return;
copy.querySelectorAll<HTMLElement>(FOCUSABLE).forEach((element) => (element.tabIndex = -1));
const source = copy.parentElement?.firstElementChild?.getAnimations()[0];
const fresh = copy.getAnimations()[0];
if (source && fresh) fresh.currentTime = source.currentTime;
}
export function Marquee({
as = "ul",
duration,
gap,
direction = "forward",
pauseOnHover = true,
copies = 3,
label,
children,
}: MarqueeProps) {
const Track = as as ElementType;
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLElement>(null);
const selfScrolling = useRef(false);
// Starts at `copies`, the same on the server and in the browser, so hydration matches.
const [tracks, setTracks] = useState(() => Math.max(1, copies));
const [scrollPaused, setScrollPaused] = useState(false);
const [userPaused, setUserPaused] = useState(false);
const [offscreen, setOffscreen] = useState(false);
const paused = scrollPaused || userPaused;
// Add copies until a period of runway covers either side of the view.
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
if (!root || !track) return;
const observer = new ResizeObserver(() => {
const step = periodOf(root, track);
if (!step) return;
const needed = Math.min(MAX_TRACKS, Math.ceil((root.clientWidth + 3 * step) / step));
setTracks((current) => Math.max(current, needed));
});
observer.observe(root);
return () => observer.disconnect();
}, []);
// Once there are enough copies, start one period in, so there is content to
// reveal in both directions. Before paint, so the row never visibly jumps.
useLayoutEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
if (!root || !track) return;
const step = periodOf(root, track);
if (step && root.scrollWidth - root.clientWidth >= 2 * step && root.scrollLeft < step) {
setScrollLeft(root, selfScrolling, step);
}
}, [tracks]);
// Pause while the user scrolls, and keep the scroll position endless.
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
if (!root || !track) return;
let resumeTimer: ReturnType<typeof setTimeout> | undefined;
const resumeAfter = (delay: number) => {
clearTimeout(resumeTimer);
resumeTimer = setTimeout(() => setScrollPaused(false), delay);
};
const onScroll = () => {
if (selfScrolling.current) return;
setScrollPaused(true);
const step = periodOf(root, track);
if (step && root.scrollWidth - root.clientWidth >= 2 * step) {
if (root.scrollLeft < step) setScrollLeft(root, selfScrolling, root.scrollLeft + step);
else if (root.scrollLeft >= 2 * step)
setScrollLeft(root, selfScrolling, root.scrollLeft - step);
}
// In case scrollend never fires, such as in an older Safari.
resumeAfter(RESUME_FALLBACK);
};
const onScrollEnd = () => resumeAfter(RESUME_AFTER_SCROLLEND);
root.addEventListener("scroll", onScroll, { passive: true });
root.addEventListener("scrollend", onScrollEnd);
return () => {
root.removeEventListener("scroll", onScroll);
root.removeEventListener("scrollend", onScrollEnd);
clearTimeout(resumeTimer);
};
}, []);
// Do not spend frames on a row nobody can see.
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const observer = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting), {
rootMargin: "200px",
});
observer.observe(root);
return () => observer.disconnect();
}, []);
const knobs = {
"--marquee-duration":
duration === undefined
? undefined
: /^[\d.]+$/.test(String(duration))
? `${duration}s`
: duration,
"--marquee-gap": gap,
} as CSSProperties;
const role = as === "div" ? undefined : "list";
return (
<div
ref={rootRef}
data-marquee=""
data-marquee-direction={direction === "reverse" ? "reverse" : undefined}
data-marquee-pause-on-hover={pauseOnHover ? "" : undefined}
data-marquee-paused={paused ? "" : undefined}
data-marquee-user-paused={userPaused ? "" : undefined}
data-marquee-offscreen={offscreen ? "" : undefined}
role={label ? "group" : undefined}
aria-label={label}
tabIndex={0}
style={knobs}
>
<Track ref={trackRef} role={role}>
{children}
</Track>
{Array.from({ length: tracks - 1 }, (_, index) => (
<Track key={index} ref={prepareCopy} role={role} data-marquee-copy="" aria-hidden="true">
{children}
</Track>
))}
<button
type="button"
data-marquee-control=""
aria-label={paused ? "Play" : "Pause"}
onClick={() => {
if (paused) {
setUserPaused(false);
setScrollPaused(false);
} else {
setUserPaused(true);
}
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path fill="currentColor" d={paused ? "M8 5v14l11-7z" : "M6 5h4v14H6zm8 0h4v14h-4z"} />
</svg>
</button>
</div>
);
}

Goes in src/components/Marquee.tsx

/*
* Marquee: an auto-scrolling row that is also a real horizontal scroller.
* Shared by the HTML, Astro and React versions. Load it in the head.
*
* Markup: a [data-marquee] element whose first child is the track. Copies of
* the track follow it, marked [data-marquee-copy]. The row only moves once a
* copy exists, so a single authored track sits still until the script (or the
* Astro component, on the server) has added copies.
*
* <div data-marquee role="group" aria-label="Partners" tabindex="0">
* <ul role="list">...</ul>
* </div>
*
* Knobs, set on the element or any ancestor:
* --marquee-gap Space between items and between copies. Default 1rem.
* --marquee-duration One loop. Default 60s.
* --marquee-control-size Pause button diameter. Default 2.5rem.
* --marquee-control-bg Pause button background. Default rgb(255 255 255 / 25%).
* --marquee-control-blur Backdrop blur behind the button. Default 8px.
* --marquee-control-color Icon and focus ring colour. Default currentColor.
* --marquee-control-border-color Button border. Default transparent.
* --marquee-control-offset Distance from the right edge. Default 0.75rem.
*
* Options as attributes:
* data-marquee-direction="reverse" Travel the other way.
* data-marquee-pause-on-hover Pause while hovered, on pointer devices.
*
* Unlayered and scoped to data-marquee attributes, so a page's reset cannot
* undo it. Change the look through the knobs above.
*/
[data-marquee] {
--_marquee-gap: var(--marquee-gap, 1rem);
--_marquee-duration: var(--marquee-duration, 60s);
position: relative;
display: flex;
align-items: start;
gap: var(--_marquee-gap);
/* Horizontal only: overflow-x alone would compute overflow-y to auto as
well, so sub-pixel overflow would get its own vertical scroller. */
overflow: auto hidden;
overscroll-behavior-inline: contain;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
/* The track and its copies, whatever element they are. The pause button
is positioned out of flow, so it is left out. */
> :not([data-marquee-control]) {
display: flex;
flex: 0 0 auto;
gap: var(--_marquee-gap);
margin: 0;
padding: 0;
list-style: none;
}
> :not([data-marquee-control]) > * {
flex: 0 0 auto;
}
/* Only move once there is a copy to loop into, and only for people who
have not asked for less motion. The row stays scrollable either way. */
@media (prefers-reduced-motion: no-preference) {
&:has(> [data-marquee-copy]) > :not([data-marquee-control]) {
animation: marquee-scroll var(--_marquee-duration) linear infinite;
}
}
&[data-marquee-direction="reverse"] > :not([data-marquee-control]) {
animation-direction: reverse;
}
/* Paused: while the user scrolls or pressed pause, while the row or an item
in it has keyboard focus, on hover where hovering is meaningful, and when
off screen. Focus on the pause button doesn't count, or pressing play
would do nothing until the button lost focus. Mouse focus doesn't either:
clicking the row focuses it (it has a tabindex), which would pause it
until the next click elsewhere. */
&[data-marquee-paused] > :not([data-marquee-control]),
&[data-marquee-offscreen] > :not([data-marquee-control]),
&:focus-visible > :not([data-marquee-control]),
&:has(:focus-visible:not([data-marquee-control])) > :not([data-marquee-control]) {
animation-play-state: paused;
}
@media (hover: hover) {
&[data-marquee-pause-on-hover]:hover > :not([data-marquee-control]) {
animation-play-state: paused;
}
}
/* The pause button. The row is its own scroller, so an absolutely
positioned child would scroll away with the content. Instead it is the
last flex item, stuck to the right edge of the scrollport, with negative
margins so it takes up no room in the row. */
> [data-marquee-control] {
--_marquee-control-color: var(--marquee-control-color, currentColor);
--_marquee-control-size: var(--marquee-control-size, 2.5rem);
position: sticky;
inset-inline-end: var(--marquee-control-offset, 0.75rem);
z-index: 1;
flex: 0 0 auto;
align-self: center;
margin-inline-start: calc(-1 * (var(--_marquee-control-size) + var(--_marquee-gap)));
margin-block: calc(-0.5 * var(--_marquee-control-size));
display: grid;
place-items: center;
inline-size: var(--_marquee-control-size);
block-size: var(--_marquee-control-size);
padding: 0;
border: 1px solid var(--marquee-control-border-color, transparent);
border-radius: 999px;
background: var(--marquee-control-bg, rgb(255 255 255 / 25%));
backdrop-filter: blur(var(--marquee-control-blur, 8px));
color: var(--_marquee-control-color);
opacity: 0;
cursor: pointer;
transition: opacity 150ms ease;
&:focus-visible {
outline: 2px solid var(--_marquee-control-color);
outline-offset: 2px;
}
/* The button does nothing without its script, so it stays hidden until then. */
&[hidden] {
display: none;
}
svg {
inline-size: 40%;
block-size: 40%;
pointer-events: none;
}
}
/* Shown on hover and focus, and kept up while paused from the button, so a
stopped row reads as paused rather than broken. */
&:hover > [data-marquee-control],
&:focus-within > [data-marquee-control],
&[data-marquee-user-paused] > [data-marquee-control] {
opacity: 1;
}
/* No hover to reveal it on touch, so it stays visible. */
@media (hover: none) {
> [data-marquee-control] {
opacity: 1;
}
}
/* Nothing moves with reduced motion, so there is nothing to pause. */
@media (prefers-reduced-motion: reduce) {
> [data-marquee-control] {
display: none;
}
}
}
@keyframes marquee-scroll {
to {
translate: calc(-100% - var(--_marquee-gap)) 0;
}
}

Goes in src/styles/marquee.css

API

Shared by every variant. Wrapper props use the same names in camelCase.

Attributes

NameTypeDescription
data-marquee-direction"reverse"Travel the other way. Leave it out to travel forward. The Astro and React versions take a direction prop.
data-marquee-pause-on-hoverboolean attributePause while hovered, on pointer devices only. The Astro and React versions take pauseOnHover, on by default.

Custom properties

NameDefaultDescription
--marquee-duration"60s"One full loop. The Astro and React versions set it from the duration prop, where a bare number is read as seconds.
--marquee-gap"1rem"Space between items, and between the end of one copy and the start of the next. The gap prop sets it.
--marquee-control-size2.5remDiameter of the pause and play button.
--marquee-control-bgrgb(255 255 255 / 25%)Background of the pause and play button, behind the blur.
--marquee-control-blur8pxBackdrop blur radius on the pause and play button.
--marquee-control-colorcurrentColorIcon colour of the pause and play button, also used for its focus ring. Defaults to the marquee's text colour.
--marquee-control-border-colortransparentBorder colour of the pause and play button. Set it when the button needs a visible edge against busy content.
--marquee-control-offset0.75remDistance from the pause and play button to the right edge.

Accessibility and SEO

Accessibility

The row is keyboard focusable and scrolls with the arrow keys. It pauses when hovered, when it or an item in it has keyboard focus, when scrolled by hand and when off screen. Reduced motion stops the animation, hides the pause button and leaves the row scrollable. Copies are aria-hidden and their links are out of the tab order. A pause and play button sits near the right edge: visible on hover or keyboard focus, while paused from the button, and always on touch, where there is no hover to reveal it. It is a real button with an accessible name that swaps between "Pause" and "Play". Without JavaScript the button stays hidden, and hover and keyboard focus still pause the row. The page still has to name the row itself, with the label prop or aria-label.

SEO

Items are in the HTML. The CSS loads in the head and changes only overflow and animation, so nothing shifts when the script runs. Copies are aria-hidden, but crawlers still read them. The page still has to keep items short, with no headings or long copy.

Notes

Manual scrolling is endless in both directions. The script moves the scroll position by one track width near either end, and the jump does not show because every copy is identical.

The HTML and Astro versions share one small script, initMarquees(), found by the data-marquee attribute. The React version is its own implementation, so it follows React’s rules rather than wrapping the script. All three share marquee.css.