@magic-spells/scrolling-content

Content that never stops.

A 2.5 kB web component that loops any row of markup forever. Hover to pause, drag to scrub, and set the speed from a media query.

infinite seamless draggable dependency‑free accessible
Install npm i @magic-spells/scrolling-content
JS (gzip) 2.5 KB
Stylesheet injected
Dependencies 0
// install npm i @magic-spells/scrolling-content // import — registers the elements and injects its own styles import '@magic-spells/scrolling-content' // or via CDN <script src="https://unpkg.com/@magic-spells/scrolling-content"></script> // use it — loose children get wrapped and cloned automatically <scrolling-content speed="60"> <scrolling-track> <span>One</span> <span>Two</span> <span>Three</span> </scrolling-track> </scrolling-content>

A logo wall that fills itself.

Give it however many items you have. The component measures one pass of your content and clones it until the track covers twice the container, so the loop never shows a gap — six logos or sixty, same markup. Clones are marked aria-hidden and inert, and their ids are stripped, so the duplication stays invisible to screen readers and to getElementById.

Northwind Ashgrove Peregrine Halcyon Meridian Stillwater
<scrolling-content speed="40"> <scrolling-track> <span class="wordmark">Northwind</span> <span class="sep-dot"></span> … </scrolling-track> </scrolling-content>

Pixels per second, either way.

speed is plain px/sec — not a duration, so adding items never changes how fast it moves. direction flips it. Both are live: set the property or the attribute and the next frame picks it up, mid-scroll, without a restart.

Composable Light DOM No deps Framework-free rAF-driven
Speed 60 px/s
Direction
// live, mid-scroll — no restart marquee.speed = 120 marquee.direction = 'right' // same thing, declaratively <scrolling-content speed="120" direction="right">…</scrolling-content>

Set the speed from a media query.

This is why v2 dropped mobile-speed / desktop-speed / breakpoint. Instead of one hard-coded breakpoint baked into the component, the speed reads from the --scrolling-content-speed custom property — so it obeys whatever breakpoints your design system already has, media queries and container queries alike. Resize this window and watch the readout change.

Viewport —
Resolved speed —
Matched rule —
110 px/s above 900 55 px/s under 900 25 px/s under 600 resize me no JS breakpoint config
.marquee { --scrolling-content-speed: 110; } @media (max-width: 900px) { .marquee { --scrolling-content-speed: 55; } } @media (max-width: 600px) { .marquee { --scrolling-content-speed: 25; } } // The value is unitless and read as px/sec. It's resolved on resize, // not per frame — reading computed style 60x a second would be a // style recalc on the hot path.

Hover to pause. Drag to scrub.

Both are on by default and both are one attribute away from off. Dragging uses pointer capture, so the gesture survives leaving the element and there are no window-level listeners to leak. On touch, touch-action: pan-y lets the browser arbitrate: vertical swipes scroll the page, horizontal ones scrub the track — no axis-detection heuristic in JS.

Grab me and throw me sideways Hover and I hold still Let go and I carry on
Toggles
Last event —
Paused false
<scrolling-content pause-on-hover="false">…</scrolling-content> <scrolling-content drag="false">…</scrolling-content> <scrolling-content paused>…</scrolling-content> // four events, all bubbling marquee.addEventListener('scrolling-content:drag-start', onScrub) marquee.addEventListener('scrolling-content:drag-end', onScrub) marquee.addEventListener('scrolling-content:start', onState) marquee.addEventListener('scrolling-content:stop', onState)

Stack them, oppose them.

Each instance owns its own loop and its own measurements, so rows at different speeds and directions stay independent. Nothing is shared and nothing is global — drop as many on a page as the design wants.

NRTH 142.60 ▲ 1.24% ASHG 88.10 ▼ 0.42% PRGN 311.75 ▲ 3.08% HLCY 27.94 ▲ 0.61% MRDN 205.33 ▼ 1.87% STWR 64.02 ▲ 0.19% slower opposite direction independent loop independent measurements no shared state fast lane 100 px/s
<scrolling-content speed="70">…</scrolling-content> <scrolling-content speed="35" direction="right">…</scrolling-content> <scrolling-content speed="100">…</scrolling-content>

Edges that dissolve.

overflow: hidden cuts items off with a hard vertical edge, which reads as a box with content sliding behind it. Add fade and the boundaries become a mask instead, so items thin out into the background — the thing that actually sells "endless". Bare fade uses a 4rem ramp; fade="8rem" or the --scrolling-content-fade property sets your own. Every marquee on this page uses it.

no fade — hard clipped edges notice the abrupt cut content just stops reads as a box fade — the 4rem default items thin out no visible boundary reads as endless fade="12rem" — a long ramp any CSS length works good for wide hero bands or a vignette look
<scrolling-content fade>…</scrolling-content> // 4rem default <scrolling-content fade="12rem">…</scrolling-content> // any CSS length /* or from the stylesheet, so it can vary by breakpoint */ .marquee { --scrolling-content-fade: 10vw; }

The unglamorous half.

Most of v2 is the boring correctness a marquee needs to survive a real page. Content that measures late — images, webfonts, an ancestor that starts hidden — is picked up by a ResizeObserver instead of a hopeful setTimeout, so the clone count is derived from real widths every time. The per-frame delta is clamped, so returning to a backgrounded tab resumes instead of teleporting. And prefers-reduced-motion stops the loop outright while leaving drag intact — the marquee becomes a scrubbable strip rather than perpetual motion.

ResizeObserver, not setTimeout delta clamped at 64 ms zero-width content can't hang the tab listeners torn down on disconnect clones hidden from AT
prefers-reduced-motion —
This marquee —

Write the markup. Then change it.

Author the <scrolling-item> yourself and the component moves nothing — it measures your element, in place, and appends clones after it. That is what makes it safe inside a framework that owns the DOM it rendered. Edit that item afterwards and a MutationObserver rebuilds every clone on the next frame, so the copies never go stale. Type below, or append a chip.

Ships as you wrote it
Your items 1
[data-clone] —
Clones in sync —
<scrolling-content speed="40" fade> <scrolling-track> <scrolling-item>…one pass of content…</scrolling-item> </scrolling-track> </scrolling-content> // edit it however you like — the clones follow on the next frame marquee.querySelector('scrolling-item').textContent = 'New promo copy'; // only your own item; clones carry data-clone, aria-hidden and inert marquee.querySelectorAll('scrolling-item:not([data-clone])');

The whole API, on one screen.

Six attributes, four custom properties, three methods, four events. That's everything.

Attributes

Attribute
Default
Description
speed
60
Scroll speed in px/sec. Overridden by --scrolling-content-speed when that property is set.
direction
left
left · right
paused
absent
Boolean. Reflected — this is the state start() and stop() write.
pause-on-hover
true
Set "false" to keep scrolling under the cursor.
fade
absent
Boolean, or a CSS length. Masks the left and right edges so content dissolves instead of clipping. Bare fade uses 4rem.
drag
true
Set "false" to disable scrubbing. Deliberately not named draggable, which is a real global HTML attribute.

CSS Custom Properties

Property
Default
Description
--scrolling-content-speed
unset
Unitless px/sec. Wins over the speed attribute, so breakpoints can drive it. Left unset by the stylesheet on purpose — a default there would always beat the attribute.
--scrolling-content-fade
4rem
Width of the edge-fade ramp. Only applies when fade is present.
--scrolling-content-gap
1rem
Gap between items, and between children inside an item.
--scrolling-content-item-padding
0
Padding applied to each <scrolling-item>.

Methods & Properties

Name
Type
Description
start()
method
Resume by clearing paused.
stop()
method
Pause by setting paused.
refresh()
method
Re-measure, top up clones, re-normalize. Called automatically on resize. Tops clones up only — it never refreshes the ones already there.
rebuild()
method
Discard every [data-clone] and refill from the source item. Called automatically when that item's content changes — reach for it only when you've replaced the <scrolling-item> element itself.
speed
property
Resolved px/sec. Assigning writes the attribute.
direction
property
'left' · 'right'
paused
property
Boolean mirror of the attribute.

Events

Name
Type
Description
scrolling-content:start
event
The rAF loop began.
scrolling-content:stop
event
The rAF loop halted — pause, hover, drag, or reduced motion.
scrolling-content:drag-start
event
A scrub gesture began.
scrolling-content:drag-end
event
A scrub gesture ended or was cancelled.

Migrating from v1

v1
v2
Notes
mobile-speed desktop-speed breakpoint
speed + --scrolling-content-speed
Removed. Using them logs a one-time warning naming the replacement.
<scrolling-track gap="30">
--scrolling-content-gap
Attribute removed in favour of the custom property.
<scrolling-item pad="10">
--scrolling-content-item-padding
Attribute removed in favour of the custom property.
stop() then hover out
stop() stays stopped
paused is now real state; hovering out no longer overrides an explicit stop.
styles written inline by JS
injected cascade layer
Layout now lives in a @layer scrolling-content stylesheet, so plain author selectors override it — no !important needed.