Marquee
Auto-scrolling row that is also a real horizontal scroller, endless in both directions.
Setup
- Load
src/styles/marquee.cssin the head. - Author one track inside
<div data-marquee role="group" aria-label="...">. - Call
initMarquees()fromsrc/scripts/marquee.tsonce 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.
Setup
The component imports the CSS and the script. It renders copies on the server (copies prop), so the row loops before JavaScript runs.
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.
---import Marquee from "/src/components/Marquee.astro";---
<Marquee label="Demo partners" duration="30s" gap="2rem"> <li> <a href="#one">Alpha</a> </li> <li> <a href="#two">Bravo</a> </li> <li> <a href="#three">Charlie</a> </li> <li> <a href="#four">Delta</a> </li> <li> <a href="#five">Echo</a> </li></Marquee>---// Plain markup, the way a page without a framework would use it: one track,// the CSS in the head, and the script to set it up.import "/src/styles/marquee.css";---
<div data-marquee data-marquee-pause-on-hover role="group" aria-label="Demo partners" style="--marquee-duration: 30s; --marquee-gap: 2rem"> <ul role="list"> <li> <a href="#one">Alpha</a> </li> <li> <a href="#two">Bravo</a> </li> <li> <a href="#three">Charlie</a> </li> <li> <a href="#four">Delta</a> </li> <li> <a href="#five">Echo</a> </li> </ul></div>
<script> import { initMarquees } from "/src/scripts/marquee"; initMarquees();</script>---// client:load, not client:only: the component renders on the server and only// reaches for the browser in its effects.import { Marquee } from "/src/components/Marquee";---
<Marquee client:load label="Demo partners" duration="30s" gap="2rem"> <li> <a href="#one">Alpha</a> </li> <li> <a href="#two">Bravo</a> </li> <li> <a href="#three">Charlie</a> </li> <li> <a href="#four">Delta</a> </li> <li> <a href="#five">Echo</a> </li></Marquee>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.css
/** * 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.ts
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
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
| Name | Type | Description |
|---|---|---|
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-hover | boolean attribute | Pause while hovered, on pointer devices only. The Astro and React versions take pauseOnHover, on by default. |
Custom properties
| Name | Default | Description |
|---|---|---|
--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-size | 2.5rem | Diameter of the pause and play button. |
--marquee-control-bg | rgb(255 255 255 / 25%) | Background of the pause and play button, behind the blur. |
--marquee-control-blur | 8px | Backdrop blur radius on the pause and play button. |
--marquee-control-color | currentColor | Icon colour of the pause and play button, also used for its focus ring. Defaults to the marquee's text colour. |
--marquee-control-border-color | transparent | Border colour of the pause and play button. Set it when the button needs a visible edge against busy content. |
--marquee-control-offset | 0.75rem | Distance 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-hiddenand 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 realbuttonwith 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 thelabelprop oraria-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.