npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

CI npm version License: Apache-2.0

A GSAP-powered infinite marquee component for smooth, continuous scrolling animations — horizontal and vertical.

Table of Contents

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 gsap

Consuming 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 transform

Data 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: auto on the container (or overflow-y for ttb / btt) so the content stays reachable by native scrolling
  • kills the drag interaction, if draggable was enabled
  • reports isPaused() === true, and ignores resume() — 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: false opts out of honoring the preference, not out of detecting it. Setting it to false means "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:

  1. Add a Div BlockContainer (the outermost wrapper)
  2. Inside it, add a Div BlockTrack
  3. Inside the track, add a Div BlockWrapper (this element gets cloned)
  4. 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. 400px or 60vh)

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 (ltrrtl, ttbbtt). 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 wizard

build 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 wizard

Contributing

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.