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: 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.cssFrom the HTML core

/**
* Marquee: the script for the HTML and Astro versions. Load `marquee.css` in
* the head as well.
*
* import { initMarquees } from "./marquee";
* initMarquees(); // every [data-marquee] on the page
*
* The CSS runs the animation. This adds what CSS cannot do:
* - clones the track until a wide screen is covered, and hides the copies from
* screen readers and the tab key;
* - makes manual scrolling endless in both directions, by moving the scroll
* position one track width whenever it nears either end. Every copy is
* identical, so the jump does not show;
* - pauses while the user scrolls, and when the row is off screen;
* - adds a pause and play button.
*
* Calling init twice on the same element does nothing. Each call returns a
* clean-up function that undoes it, for pages that swap content in and out.
*/
const RESUME_AFTER_SCROLLEND = 600;
const RESUME_FALLBACK = 2000;
const MAX_COPIES = 24;
const FOCUSABLE = "a[href], button, input, select, textarea, [tabindex]";
const PLAY_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>`;
const PAUSE_ICON = `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z"/></svg>`;
const active = new WeakMap<HTMLElement, () => void>();
/**
* Copies are decoration: hidden from assistive tech and skipped by the tab key,
* but still hoverable and clickable. A copy is often what is under the pointer,
* so `inert`, which blocks clicks, is wrong here.
*/
function markCopy(copy: HTMLElement) {
copy.setAttribute("data-marquee-copy", "");
copy.setAttribute("aria-hidden", "true");
copy.querySelectorAll<HTMLElement>(FOCUSABLE).forEach((element) => (element.tabIndex = -1));
}
function createControl(root: HTMLElement, added: Element[]): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.setAttribute("data-marquee-control", "");
root.append(button);
added.push(button);
return button;
}
/** Sets up one marquee. Returns a function that undoes it. */
export function initMarquee(root: HTMLElement): () => void {
const existing = active.get(root);
if (existing) return existing;
const track = root.firstElementChild as HTMLElement | null;
if (!track) return () => {};
const controller = new AbortController();
const { signal } = controller;
const added: Element[] = [];
let resumeTimer: ReturnType<typeof setTimeout> | undefined;
let selfScrolling = false;
let userPaused = false;
// A scrollable region must be reachable by keyboard, so it can be panned
// with the arrow keys. Naming it (`aria-label`) is left to the page.
if (!root.hasAttribute("tabindex")) root.tabIndex = 0;
root
.querySelectorAll<HTMLElement>(":scope > [data-marquee-copy]")
.forEach((copy) => markCopy(copy));
/* ---- Pause button ---- */
// The Astro version renders the button, hidden until now. Plain HTML may leave it out.
const control =
root.querySelector<HTMLButtonElement>(":scope > [data-marquee-control]") ??
createControl(root, added);
control.hidden = false;
const updateControl = () => {
const paused = root.hasAttribute("data-marquee-paused");
const label = paused ? "Play" : "Pause";
// Scrolling calls this on every event, so skip it when nothing changed.
if (control.getAttribute("aria-label") === label) return;
control.setAttribute("aria-label", label);
control.innerHTML = paused ? PLAY_ICON : PAUSE_ICON;
};
const pause = () => {
clearTimeout(resumeTimer);
root.setAttribute("data-marquee-paused", "");
updateControl();
};
const resume = (delay = 0) => {
// A pause from the button holds until the button is pressed again.
if (userPaused) return;
clearTimeout(resumeTimer);
const run = () => {
root.removeAttribute("data-marquee-paused");
updateControl();
};
if (delay) resumeTimer = setTimeout(run, delay);
else run();
};
control.addEventListener(
"click",
() => {
userPaused = !root.hasAttribute("data-marquee-paused");
root.toggleAttribute("data-marquee-user-paused", userPaused);
if (userPaused) pause();
else resume();
},
{ signal },
);
updateControl();
/* ---- Copies and endless scrolling ---- */
/** From the start of one copy to the start of the next. */
const period = () => {
const gap = parseFloat(getComputedStyle(root).columnGap) || 0;
return track.getBoundingClientRect().width + gap;
};
const setScrollLeft = (value: number) => {
selfScrolling = true;
root.scrollLeft = value;
requestAnimationFrame(() => (selfScrolling = false));
};
/** Clones the track until there is a period of runway either side of the view. */
const fill = () => {
const step = period();
if (!step) return;
let budget = MAX_COPIES - root.children.length;
while (root.scrollWidth < root.clientWidth + 3 * step && budget-- > 0) {
const copy = track.cloneNode(true) as HTMLElement;
root.insertBefore(copy, control);
markCopy(copy);
added.push(copy);
// A new copy starts its animation at zero. Match the track, so every copy
// stays in step and the scroll jump stays invisible.
const [source] = track.getAnimations();
const [fresh] = copy.getAnimations();
if (source && fresh) fresh.currentTime = source.currentTime;
}
};
const layout = () => {
fill();
// Start one period in, so there is content to reveal in both directions.
const step = period();
if (step && root.scrollLeft < step) setScrollLeft(step);
};
/** Keeps the scroll position between one and two periods in. */
const wrap = () => {
const step = period();
if (!step || root.scrollWidth - root.clientWidth < 2 * step) return;
if (root.scrollLeft < step) setScrollLeft(root.scrollLeft + step);
else if (root.scrollLeft >= 2 * step) setScrollLeft(root.scrollLeft - step);
};
root.addEventListener(
"scroll",
() => {
if (selfScrolling) return;
pause();
wrap();
// In case scrollend never fires, such as in an older Safari.
resume(RESUME_FALLBACK);
},
{ passive: true, signal },
);
root.addEventListener("scrollend", () => resume(RESUME_AFTER_SCROLLEND), { signal });
// Track widths settle after layout and change with the viewport.
const resizeObserver = new ResizeObserver(layout);
resizeObserver.observe(root);
// Do not spend frames on a row nobody can see.
const intersectionObserver = new IntersectionObserver(
([entry]) => root.toggleAttribute("data-marquee-offscreen", !entry.isIntersecting),
{ rootMargin: "200px" },
);
intersectionObserver.observe(root);
const cleanUp = () => {
controller.abort();
resizeObserver.disconnect();
intersectionObserver.disconnect();
clearTimeout(resumeTimer);
added.forEach((element) => element.remove());
for (const name of [
"data-marquee-paused",
"data-marquee-user-paused",
"data-marquee-offscreen",
]) {
root.removeAttribute(name);
}
active.delete(root);
};
active.set(root, cleanUp);
return cleanUp;
}
/** Sets up every `[data-marquee]` inside `scope`. Returns one function that undoes them all. */
export function initMarquees(scope: ParentNode = document): () => void {
const cleanUps = Array.from(scope.querySelectorAll<HTMLElement>("[data-marquee]"), initMarquee);
return () => cleanUps.forEach((cleanUp) => cleanUp());
}

Goes in src/scripts/marquee.tsFrom the HTML core

---
/**
* Marquee for Astro: the HTML version as a component. It renders the copies on
* the server, so the row loops before any JavaScript runs, and the script adds
* the rest (see src/scripts/marquee.ts).
*
* <Marquee label="Partners" duration="40s">
* <li>One</li>
* <li>Two</li>
* </Marquee>
*/
import type { HTMLAttributes } from "astro/types";
import "../styles/marquee.css";
interface Props extends HTMLAttributes<"div"> {
/** Element used for the track. Slot 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 rendered on the server. The script adds more on wide
* screens; this only sets how much loops before it runs.
*/
copies?: number;
/** Names the scrollable region for screen readers and keyboard users. */
label?: string;
}
const {
as: Track = "ul",
duration,
gap,
direction = "forward",
pauseOnHover = true,
copies = 3,
label,
style,
...rest
} = Astro.props;
// Knobs go in as custom properties, so a page can still restyle them with CSS.
const knobs = [
duration === undefined
? ""
: `--marquee-duration: ${/^[\d.]+$/.test(String(duration)) ? `${duration}s` : duration};`,
gap === undefined ? "" : `--marquee-gap: ${gap};`,
typeof style === "string" ? style : "",
]
.join(" ")
.trim();
// Rendered once, then repeated, so every copy is identical by construction.
const trackHtml = await Astro.slots.render("default");
---
<div
data-marquee
data-marquee-direction={direction === "reverse" ? "reverse" : undefined}
data-marquee-pause-on-hover={pauseOnHover ? "" : undefined}
role={label ? "group" : undefined}
aria-label={label}
tabindex="0"
style={knobs || undefined}
{...rest}
>
{Array.from({ length: Math.max(1, copies) }, (_, copy) => (
<Track
role={Track === "div" ? undefined : "list"}
data-marquee-copy={copy > 0 ? "" : undefined}
aria-hidden={copy > 0 ? "true" : undefined}
set:html={trackHtml}
/>
))}
<button type="button" data-marquee-control aria-label="Pause" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z"></path>
</svg>
</button>
</div>
<script>
import { initMarquees } from "../scripts/marquee";
// Safe to call again: each marquee is only set up once.
initMarquees();
document.addEventListener("astro:page-load", () => initMarquees());
</script>

Goes in src/components/Marquee.astro

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.