ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

Marquee

An infinite strip (logos, taglines) that pauses on hover.

Use it for: Logo walls, ticker bands. The honest replacement for Swiper loop.
Knobs: --sv-marquee-duration (one loop), --sv-gap; --sv-marquee-hover: running disables the pause

ScrollVars·one rAF in· CSS variables out·

Install: npx scrollvars add marquee
Styles: import 'scrollvars/styles/ui.css'

<div id="logo-strip" class="sv-marquee sv-marquee-controlled [--sv-marquee-duration:24s] [--sv-gap:64px] py-8">
  <div class="sv-marquee-track">
    {logos}{/* duplicate once, aria-hidden */}
  </div>
  <button type="button" class="sv-marquee-pause" aria-pressed="false"
    data-sv-toggle="sv-paused" data-sv-target=".sv-marquee-track">Pause animation</button>
</div>
<!-- <ScrollVarsBoot /> already wires this; a plain HTML page calls
     toggles() itself: SV.toggles(document.getElementById('logo-strip')) -->
<div class="sv-marquee sv-marquee-controlled">
  <div class="sv-marquee-track">
    …content… …content again (aria-hidden)…
  </div>
  <!-- WCAG 2.2.2: auto-moving content past 5s needs a pause/stop control -->
  <button type="button" class="sv-marquee-pause" aria-pressed="false"
    data-sv-toggle="sv-paused" data-sv-target=".sv-marquee-track">Pause animation</button>
</div>
<script>toggles()</script> <!-- wires the button: aria-pressed, sv-paused on the track -->

/* the preset (styles/ui.css): */
.sv-marquee { overflow: hidden; display: flex; }
.sv-marquee-track { display: flex; gap: var(--sv-gap, 48px); width: max-content;
  padding-right: var(--sv-gap, 48px);
  animation: sv-marquee var(--sv-marquee-duration, 30s) linear infinite; }
.sv-marquee:hover .sv-marquee-track,
.sv-marquee:focus-within .sv-marquee-track { animation-play-state: paused; }
.sv-marquee-controlled > .sv-marquee-track.sv-paused { animation-play-state: paused; }
/* while the track holds focus, present it statically and wrapped (WCAG
   2.4.7): pausing alone can leave a focused item translated off the strip */
.sv-marquee-track:focus-within { animation: none; width: auto; flex-wrap: wrap; }
.sv-marquee-track:focus-within > .sv-marquee-dup { display: none; }
@keyframes sv-marquee { to { translate: -50% 0; } }

/* the same sheet's reduced-motion override, last so it wins on source order:
   without width/flex-wrap the strip keeps overflowing past the viewport
   with nothing left to scroll it into view */
@media (prefers-reduced-motion: reduce) {
  .sv-marquee-track { animation: none; width: auto; flex-wrap: wrap; }
  .sv-marquee-track > .sv-marquee-dup { display: none; }
}
/* the same under html[data-sv-motion="reduce"], the site's own switch */
:where([data-sv-motion="reduce"]) .sv-marquee-track { animation: none; width: auto; flex-wrap: wrap; }
:where([data-sv-motion="reduce"]) .sv-marquee-track > .sv-marquee-dup { display: none; }
<Marquee speed={24}>
  {logos.map(l => <img key={l.id} src={l.src} alt={l.name} className="h-8" />)}
</Marquee>

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.