@refokus-agency/marquee
v1.5.1
Published
GSAP-powered infinite marquee for horizontal and vertical seamless scrolling, with optional drag, pause-on-hover, and data-attribute configuration. Waits for images and defers init until in view.
Readme
@refokus-agency/marquee
A GSAP-powered infinite marquee component for smooth, continuous scrolling animations — horizontal and vertical.
Table of Contents
- Features
- Requirements
- Installation
- Usage
- Accessibility
- Webflow Setup
- API Reference
- Development
- Publishing
- Contributing
- Code of Conduct
- Security
- Changelog
- License
Features
- Infinite seamless loop animation
- Waits for images to load before calculating dimensions
- Deferred init — waits until the container enters the viewport (lazy-load friendly)
- Horizontal (
ltr/rtl) and vertical (ttb/btt) scroll directions - Adjustable scroll speed
- Optional drag/touch interaction
- Pause on hover option
- Honors
prefers-reduced-motion— freezes and becomes scrollable instead - Dynamic cloning based on container size (auto add/remove on resize)
- Full TypeScript support
- Programmatic control (pause, resume, destroy)
- Debounced resize handling (150ms)
Requirements
- Node.js >= 22.0.0
- GSAP >= 3.12.0 (peer dependency)
- An ESM environment — see below
ESM only
This package ships as ES modules and declares no require condition, so
require('@refokus-agency/marquee') fails with ERR_PACKAGE_PATH_NOT_EXPORTED.
Import it instead:
import { initMarquee } from '@refokus-agency/marquee';From CommonJS, use a dynamic import:
const { initMarquee } = await import('@refokus-agency/marquee');Installation
# pnpm
pnpm add @refokus-agency/marquee gsap
# npm
npm install @refokus-agency/marquee gsapConsuming the package works with any package manager. Contributing to it does not — the development setup is pnpm-only, see CONTRIBUTING.md.
Usage
HTML Structure
The marquee requires a strict 3-level DOM structure:
| Level | Role | Description |
|-------|------|-------------|
| Grandparent | Container | Clips overflow, provides size for clone calculations |
| Parent | Track | Receives the GSAP transform |
| Child | Wrapper [data-marquee] | Gets cloned to fill the track seamlessly |
Horizontal (LTR / RTL)
<div class="marquee-container">
<div class="marquee-track">
<div data-marquee class="marquee-wrapper">
<div data-marquee-item>Item 1</div>
<div data-marquee-item>Item 2</div>
<div data-marquee-item>Item 3</div>
</div>
<!-- clones are automatically appended here -->
</div>
</div>.marquee-container {
max-width: 100%;
overflow: hidden;
}
.marquee-track {
display: flex;
width: max-content;
}
.marquee-wrapper {
display: flex;
flex-shrink: 0;
}
[data-marquee-item] {
flex-shrink: 0;
}Vertical (TTB / BTT)
The container needs a fixed height. The track stacks items in a column.
<div class="marquee-container-vertical">
<div class="marquee-track-vertical">
<div data-marquee data-marquee-direction="ttb" class="marquee-wrapper-vertical">
<div data-marquee-item>Item 1</div>
<div data-marquee-item>Item 2</div>
<div data-marquee-item>Item 3</div>
</div>
</div>
</div>.marquee-container-vertical {
overflow: hidden;
height: 400px; /* required — defines the visible window */
}
.marquee-track-vertical {
display: flex;
flex-direction: column;
height: max-content;
}
.marquee-wrapper-vertical {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
[data-marquee-item] {
flex-shrink: 0;
}Basic Usage
import { initMarquee } from '@refokus-agency/marquee';
// Reads direction/speed/draggable/pauseOnHover from data attributes
const marquees = await initMarquee();With Options
import { initMarquee } from '@refokus-agency/marquee';
const marquees = await initMarquee({
speed: 2, // Speed multiplier (default: 1)
direction: 'rtl', // 'ltr' | 'rtl' | 'ttb' | 'btt' (default: 'ltr')
draggable: true, // Enable drag/touch (default: false)
pauseOnHover: true, // Pause on hover (default: false)
dragEase: 0.5, // Drag easing in seconds (default: 0.5)
});Custom Selector
const marquees = await initMarquee({
wrapperSelector: '.my-marquee',
});Using the Marquee Class Directly
import { Marquee } from '@refokus-agency/marquee';
const el = document.querySelector('[data-marquee]');
// Recommended: static async factory (waits for images)
const marquee = await Marquee.create(el, {
speed: 1.5,
direction: 'ttb',
});
// Alternative: constructor + ready promise
const marquee = new Marquee(el, { speed: 1.5 });
await marquee.ready;
marquee.pause();
marquee.resume();
marquee.setSpeed(2);
marquee.setDirection('btt');
marquee.destroy();Factory Function
import { createMarquee } from '@refokus-agency/marquee';
// By CSS selector
const marquee = await createMarquee('#my-marquee', { speed: 1.5 });
// By element reference
const marquee = await createMarquee(element, { direction: 'ttb' });Instance Control
const [marquee] = await initMarquee();
marquee.pause();
marquee.resume();
marquee.isPaused(); // boolean
marquee.setSpeed(2);
marquee.getSpeed(); // 2
marquee.setDirection('rtl');
marquee.getDirection(); // 'rtl'
marquee.isReady(); // true after images loaded
marquee.isDestroyed(); // false
marquee.destroy(); // removes clones, listeners, resets transformData Attributes
Configure each marquee instance directly in HTML — no JS config needed when using initMarquee().
<!-- Vertical top-to-bottom, slow speed, pause on hover -->
<div class="container">
<div class="track">
<div
data-marquee
data-marquee-direction="ttb"
data-marquee-speed="0.5"
data-marquee-pause-on-hover="true"
>
<div data-marquee-item>Item 1</div>
<div data-marquee-item>Item 2</div>
</div>
</div>
</div>
<!-- RTL with drag enabled -->
<div class="container">
<div class="track">
<div
data-marquee
data-marquee-direction="rtl"
data-marquee-draggable="true"
>
<div data-marquee-item>Item A</div>
<div data-marquee-item>Item B</div>
</div>
</div>
</div>All supported attributes:
| Attribute | Values | Default |
|-----------|--------|---------|
| data-marquee | (empty — marks the wrapper) | — |
| data-marquee-direction | ltr | rtl | ttb | btt | ltr |
| data-marquee-speed | any number, e.g. 2 | 1 |
| data-marquee-draggable | true | false | false |
| data-marquee-pause-on-hover | true | false | false |
| data-marquee-respect-reduced-motion | true | false | true |
Accessibility
Reduced Motion
By default the marquee honors the operating system's reduced-motion setting. While
(prefers-reduced-motion: reduce) matches, the marquee:
- stops advancing and resets to its start position
- sets
overflow-x: autoon the container (oroverflow-yforttb/btt) so the content stays reachable by native scrolling - kills the drag interaction, if
draggablewas enabled - reports
isPaused() === true, and ignoresresume()— the preference outranks it, so nothing moves until the preference itself changes
The scroll affordance matters: a frozen marquee that still clips its overflow would hide every item past the container edge with no way to reach them. Native scrolling replaces the animation as the mechanism for getting to that content — which is also why drag is dropped rather than kept. Drag and native scroll compete for the same gesture on the same axis, and on touch they fight outright, so the one that guarantees reachability wins.
This is the only style the library writes on an element you own. Whatever inline overflow-x /
overflow-y the container already had is recorded and restored verbatim when the preference turns
off or the instance is destroyed. Nothing else on the container is read or written — including its
scroll offsets, which are only ever reset on the axis the library itself made scrollable.
On platforms with classic scrollbars (Windows), the scrollbar appearing can shift the layout around the marquee. Reserve the space if that matters to you:
.marquee-container {
scrollbar-gutter: stable;
}The preference is watched live, not read once: flipping it at the OS level freezes or resumes an already-running marquee.
To animate regardless of the preference, opt out:
// Per instance
const marquee = await createMarquee('#my-marquee', { respectReducedMotion: false });
// Or for every marquee on the page
await initMarquee({ respectReducedMotion: false });<!-- Or per element, with initMarquee() -->
<div data-marquee data-marquee-respect-reduced-motion="false">…</div>
respectReducedMotion: falseopts out of honoring the preference, not out of detecting it. Setting it tofalsemeans "animate anyway", so use it only where motion is essential to what the content communicates.
Browsers that cannot report the preference at all animate normally, as does any environment without
window.matchMedia.
Known limitations
The live watching runs on gsap.matchMedia(), which brings two GSAP behaviors with it. Neither is
specific to this library — they affect every gsap.matchMedia() consumer — but both are worth
knowing about.
Two preference changes less than 2ms apart: the second is dropped. GSAP coalesces media-change
events into one pass every 2ms, shared across every gsap.matchMedia() user on the page. That coalescing is what keeps
one preference change from being processed once per registered query, but it cannot tell duplicate
events apart from two genuinely different values. When the second value is dropped, GSAP's record of
the preference does not advance either, so the marquee can stay out of sync with the real setting
until the next media change anywhere on the page resynchronizes it.
In practice this is a testing concern, not a user-facing one: the first change after page load is
always applied, and nobody can toggle an OS setting twice within 2ms. What can is an automated
suite driving the preference through something like Playwright's emulateMediaFeatures(). If you
assert reduced-motion behavior in tests, leave more than 2ms between flips — otherwise a pass or a
failure may be measuring the dropped event rather than the marquee.
The native media-query listener outlives destroy(). destroy() releases the
gsap.matchMedia() context, but GSAP never calls removeListener on the underlying
MediaQueryList, and exposes no API to do it. The destroyed instance is still garbage-collectible —
GSAP's handler is a module-level function holding no per-instance state — but the native listener
count grows across mount/unmount cycles in SPA-style usage.
Clones and the Accessibility Tree
The marquee fills the track by cloning its wrapper. Every clone gets aria-hidden="true", and every
focusable element inside it gets tabindex="-1" — otherwise a screen reader would announce each item
several times over, and every cloned link would become a duplicate tab stop.
Both go on every clone the marquee creates, independent of respectReducedMotion and of the motion
preference. Anything focusable inside a marquee is reachable by keyboard and by assistive
technology exactly once, in the original wrapper. Clones stay fully clickable, so a reader can
activate whichever copy of a link happens to be under the pointer.
That last part is why inert is not used, despite covering both exclusions in a single attribute.
inert also blocks hit-testing, and the track only ever translates by one period — so the original
wrapper occupies at most its own width of the container, and most of what the reader sees at any
moment is a clone. Under inert the majority of a marquee's links would silently stop responding to
clicks, which reads as a broken site rather than a library limitation. aria-hidden and tabindex
also work in every browser, where inert needs Chrome 102+, Safari 15.5+ or Firefox 112+ to do
anything at all.
The tradeoff this leaves: a pointer can still move focus into an aria-hidden subtree, so an
accessibility audit will flag aria-hidden-focus as needs-review rather than passing clean. Mouse
users who click a cloned link navigate immediately, and keyboard and screen-reader users never reach
one, so the flag is expected here.
Webflow Setup
1 — DOM Structure
Build the 3-level div structure in the Designer:
- Add a Div Block → Container (the outermost wrapper)
- Inside it, add a Div Block → Track
- Inside the track, add a Div Block → Wrapper (this element gets cloned)
- Inside the wrapper, add your content items (logo images, cards, text, etc.)
2 — Custom Attributes
Select the Wrapper div, open Element Settings → Custom Attributes, and add:
| Attribute | Value |
|-----------|-------|
| data-marquee | (leave value empty) |
| data-marquee-direction | ltr, rtl, ttb, or btt |
| data-marquee-speed | e.g. 2 |
| data-marquee-draggable | true or false |
| data-marquee-pause-on-hover | true or false |
Only data-marquee is required. The others are optional and fall back to defaults.
3 — CSS (Horizontal)
In the Style Panel, apply these styles to each level:
Container div
- Overflow: Hidden
Track div
- Display: Flex
- Width: Max Content
Wrapper div
- Display: Flex
- Flex Shrink: 0
Each item inside the wrapper
- Flex Shrink: 0
3 — CSS (Vertical — TTB or BTT)
Container div
- Overflow: Hidden
- Height: (fixed value — e.g.
400pxor60vh)
Track div
- Display: Flex
- Flex Direction: Column
- Height: Max Content
Wrapper div
- Display: Flex
- Flex Direction: Column
- Flex Shrink: 0
Each item inside the wrapper
- Flex Shrink: 0
4 — Script Embed
In Project Settings → Custom Code, paste before the </body> tag:
<script type="module">
import { initMarquee } from 'https://cdn.jsdelivr.net/npm/@refokus-agency/[email protected]/+esm';
await initMarquee();
</script>Replace @X.Y.Z with a real version. Take the current number from the
latest release or the npm badge at the top
of this page. Use @X (e.g. @1) instead if you want to track the newest 1.x automatically and
accept the patch and minor updates that come with it.
No separate GSAP tag is needed: jsDelivr's /+esm endpoint resolves the peer dependency and
ships it alongside the package.
initMarquee()scans the page for[data-marquee]elements and reads all configuration from their data attributes automatically.
If the page already loads GSAP
/+esm bundles its own copy of GSAP. On a page that already has one, you pay for two GSAP
cores — roughly 70 kB of duplicated payload and a second ticker loop. Marquee still animates
correctly, but it runs on an instance your own code cannot see: shared state such as a global
timeline, gsap.matchMedia() contexts, or plugins you registered on the page's core does not
carry across.
Reduced motion is unaffected by this: marquee's own gsap.matchMedia() context
only runs callbacks — it never creates tweens — so it works on whichever core the package imported.
To run marquee on the GSAP you already have, load the browser bundle directly and map the gsap
specifiers onto the existing global. An import map can only point a specifier at a URL, so the
global is re-exported through a tiny inline shim module:
<!-- The GSAP you already load, in whatever form -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/Observer.min.js"></script>
<script type="importmap">
{
"imports": {
"gsap": "data:text/javascript,export const gsap = window.gsap; export default window.gsap;",
"gsap/dist/Observer": "data:text/javascript,export const Observer = window.Observer; export default window.Observer;"
}
}
</script>
<script type="module">
import { initMarquee } from 'https://cdn.jsdelivr.net/npm/@refokus-agency/[email protected]/dist/marquee.browser.js';
await initMarquee();
</script>marquee.browser.js keeps gsap and gsap/dist/Observer as bare imports, so the import map
decides what they resolve to — here, the single instance already on window. Pointing those keys
at a CDN URL such as gsap@3/+esm would not achieve this: that fetches a fresh, isolated core
and leaves you back at two instances.
Replace @X.Y.Z here as well. This path requires a release that ships
dist/marquee.browser.js — it does not exist in versions published before that bundle was added,
so pin at or above the first release containing it rather than reusing an older number.
docs/examples/local/index.html in this repository is a working version of this setup, using
separate shim files instead of inline data: URLs.
API Reference
MarqueeOptions
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| speed | number | 1 | Animation speed multiplier |
| direction | 'ltr' \| 'rtl' \| 'ttb' \| 'btt' | 'ltr' | Scroll direction |
| draggable | boolean | false | Enable drag/touch interaction |
| pauseOnHover | boolean | false | Pause animation on hover |
| dragEase | number | 0.5 | Drag easing duration in seconds |
| respectReducedMotion | boolean | true | Honor prefers-reduced-motion — see Reduced Motion |
MarqueeConfig (extends MarqueeOptions)
Additional options for initMarquee():
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| wrapperSelector | string | '[data-marquee]' | Selector for wrapper elements |
| itemSelector | string | '[data-marquee-item]' | Deprecated — has no effect and is ignored. Kept for backward compatibility; will be removed in 2.0.0 (#67) |
| directionAttribute | string | 'data-marquee-direction' | Attribute name for direction |
| speedAttribute | string | 'data-marquee-speed' | Attribute name for speed |
| draggableAttribute | string | 'data-marquee-draggable' | Attribute name for draggable |
| pauseOnHoverAttribute | string | 'data-marquee-pause-on-hover' | Attribute name for pauseOnHover |
| respectReducedMotionAttribute | string | 'data-marquee-respect-reduced-motion' | Attribute name for respectReducedMotion |
Marquee Instance Methods
| Method | Return | Description |
|--------|--------|-------------|
| pause() | void | Pause the animation |
| resume() | void | Resume the animation — no effect while reduced motion is active, see Reduced Motion |
| isPaused() | boolean | Check if paused — also true while reduced motion is active |
| isReady() | boolean | True after images loaded and init complete |
| setSpeed(speed) | void | Update scroll speed |
| getSpeed() | number | Get current speed |
| setDirection(dir) | void | Update scroll direction — same axis only (ltr ↔ rtl, ttb ↔ btt). Crossing axes is not supported |
| getDirection() | MarqueeDirection | Get current direction |
| isDestroyed() | boolean | Check if destroyed |
| destroy() | void | Clean up clones, listeners, and transforms |
Marquee Instance Properties
| Property | Type | Description |
|----------|------|-------------|
| element | HTMLElement | The wrapper element (readonly) |
| ready | Promise<void> | Resolves when images loaded and initialized |
Functions
// Initialize all matching elements on the page
initMarquee(config?: MarqueeConfig): Promise<Marquee[]>
// Create a single instance by element or selector
createMarquee(element: HTMLElement | string, options?: MarqueeOptions): Promise<Marquee | null>Development
pnpm build # Compile TypeScript + browser bundle, then validate the package
pnpm build:clean # Clean dist and rebuild
pnpm build:watch # Vite watch mode (no validation)
pnpm build:watch:types # TypeScript watch mode
pnpm test # Run tests
pnpm typecheck # TypeScript type check
pnpm lint # Lint with Biome (--write)
pnpm format # Format with Biome (--write)
pnpm validate:package # Entry-point rules + publint + attw (runs as part of build)
pnpm commit # Conventional commit wizardbuild ends with validate:package, which asserts the entry-point shape
(scripts/validate-exports.mjs), then runs publint and
attw --pack --profile esm-only against a real tarball. A packaging mistake fails the build
rather than reaching npm. build:watch skips it, so iteration stays fast.
Publishing
Releases are automated with semantic-release and commits must follow Conventional Commits — the version number is derived from the commit history.
Published versions are available on npm as
@refokus-agency/marquee.
prepublishOnly runs typecheck, lint and build:clean before npm accepts the tarball, so
validate:package executes a second time at publish — once in CI and once against the exact
artifact being uploaded. The repeated work is deliberate: it is the last gate before a broken
entry-point map becomes a published version.
pnpm commit # Use the commit wizardContributing
Contributions are welcome. See CONTRIBUTING.md for the development setup, commit conventions, and pull request process.
Code of Conduct
This project adheres to the Contributor Covenant code of conduct. By participating, you are expected to uphold it. Please report unacceptable behavior to [email protected].
Security
To report a vulnerability, follow the process described in SECURITY.md — please do not open a public issue for security reports.
Changelog
Release notes for every version are published on the GitHub Releases page.
License
Licensed under the Apache License, Version 2.0 (Apache-2.0). See
LICENSE for the full license text and NOTICE for
attribution requirements.
