tool / scroll marqueecss · javascript · react

Scroll marquee generator

Rows of big text that slide sideways as the page scrolls, alternate rows the other way, with an endless CSS drift underneath that the scroll adds to. Set the rows, the words, how far a screen of scroll moves them and how fast they drift, scroll the box to watch it, and copy the HTML, CSS and JavaScript, a React component, or a version in CSS alone.

Rows
up to 4
Separators
4
Code outputs
5
Library
2.27.3
the windowbeforeScrollDrawRevealPinParallaxScrollDrawRevealPinParallaxScrollDrawRevealPinParallaxZero dependenciesMITNative CSSZero dependenciesMITNative CSSZero dependenciesMITNative CSSScrollDrawRevealPinParallaxScrollDrawRevealPinParallaxScrollDrawRevealPinParallaxZero dependenciesMITNative CSSZero dependenciesMITNative CSSZero dependenciesMITNative CSSScrollDrawRevealPinParallaxScrollDrawRevealPinParallaxScrollDrawRevealPinParallaxZero dependenciesMITNative CSSZero dependenciesMITNative CSSZero dependenciesMITNative CSSafter one screenScrollDrawRevealPinParallaxScrollDrawRevealPinParallaxScrollDrawRevealPinParallaxZero dependenciesMITNative CSSZero dependenciesMITNative CSSZero dependenciesMITNative CSSrow 1 · −300pxrow 2 · +300pxone screen of scroll
fig. 1 · 300px per screen, rows in opposite directions
01The tool

Make your scroll marquee

rows · 2
distance · 300px per screen
text size · 64px
gap · 48px
separator
drift · a loop every 40s
preview with
↓ scroll inside the box
  • Scroll
  • Draw
  • Reveal
  • Pin
  • Parallax
  • Zero dependencies
  • MIT
  • Native CSS
the page goes on

row 1 · p 0.00

in this box

row 1: translateX(0px) → (−300px)row 2: translateX(−300px) → (0px)

Your system asks for reduced motion, so the preview does what the code does for you: nothing moves, the copy is hidden and every item wraps onto the rows, in both versions.

index.html45 lines
<!-- Made with https://svg-scroll-draw.vercel.app/scroll-marquee-generator -->
<!-- Each row holds its items twice: the second copy makes the loop seamless,
     and aria-hidden keeps screen readers to the first. -->
<section class="mq" aria-label="Highlights">
  <label class="mq-pause"><input type="checkbox" /> Pause motion</label>
  <div class="mq-row">
    <div class="mq-shift">
      <div class="mq-track">
        <ul class="mq-set">
          <li class="mq-item">Scroll</li>
          <li class="mq-item">Draw</li>
          <li class="mq-item">Reveal</li>
          <li class="mq-item">Pin</li>
          <li class="mq-item">Parallax</li>
        </ul>
        <ul class="mq-set" aria-hidden="true">
          <li class="mq-item">Scroll</li>
          <li class="mq-item">Draw</li>
          <li class="mq-item">Reveal</li>
          <li class="mq-item">Pin</li>
          <li class="mq-item">Parallax</li>
        </ul>
      </div>
    </div>
  </div>
  <div class="mq-row is-right">
    <div class="mq-shift">
      <div class="mq-track">
        <ul class="mq-set">
          <li class="mq-item">Zero dependencies</li>
          <li class="mq-item">MIT</li>
          <li class="mq-item">Native CSS</li>
        </ul>
        <ul class="mq-set" aria-hidden="true">
          <li class="mq-item">Zero dependencies</li>
          <li class="mq-item">MIT</li>
          <li class="mq-item">Native CSS</li>
        </ul>
      </div>
    </div>
  </div>
</section>

<!-- The script version. The CSS-only version needs no script. -->
<script type="module" src="main.js"></script>

Three files: the HTML, the CSS and main.js. The CSS does the drift and the pause; main.js makes the rows follow the scroll, in every browser.

Run it
The preview
The box stands in for the window. With scrollAnimate it runs the library on each row with the box as its scrollContainer; with CSS only it runs the generated view() timeline, which measures against the nearest scroll container, the box. Either way a box’s height of scroll moves each row the distance you set.
02The model

Two motions, one row

Each row is three boxes, one inside the other. The row clips. The shift inside it follows the scroll. The track inside that drifts on its own, forever. Because the shift and the drift are on different elements, their transforms add up: scroll and the rows move faster, stop and they keep drifting.

one-row.html8 lines
<div class="mq-row">            <!-- clips: overflow: clip -->
  <div class="mq-shift">          <!-- follows the scroll -->
    <div class="mq-track">        <!-- drifts: 0 → −50%, forever -->
      <ul class="mq-set">…</ul>
      <ul class="mq-set" aria-hidden="true">…</ul>
    </div>
  </div>
</div>

The drift is translateX(0) to translateX(−50%) on the track, which holds the items twice: −50% is one copy, so when the loop restarts the second copy is exactly where the first began. The gap goes inside each copy as padding, half at each end, so the gap across the seam is the same as the gaps between items.

The shift is a px translateX spread over one screen of scroll: from the row’s top at the window’s bottom to its top at the window’s top (fig. 1). Rows that run left go from 0 to −distance; rows that run right from −distance back to 0. Both stay between −distance and 0, and each copy is at least the row’s width plus the distance, min-width: calc(100cqw + distance), so neither motion ever shows the row’s empty end.

main.js8 lines
import { scrollAnimate } from 'svg-scroll-draw';

// 300px to the left for every screen of scroll.
scrollAnimate('.mq-shift', {
  props: { transform: ['translateX(0px)', 'translateX(-300px)'] },
  trigger: { start: 'top bottom', end: 'top top' },
  easing: 'linear',
});

The same window in CSS, on the browser’s scroll timeline:

marquee.css6 lines
.mq-shift {
  animation: mq-shift linear both;
  animation-timeline: view();
  /* the row's top at the window's bottom → its top at the window's top */
  animation-range: entry 0% exit 0%;
}
Why not scrollParallax
scrollParallax with axis: 'x' moves an element a share of its own width, and the track’s width is whatever its copies add up to. A marquee wants a distance you choose in px for a screen of scroll, which is scrollAnimate with a px transform and a one-screen trigger.
03Two ways

Script or CSS: what each costs

The generator writes both, from the same HTML. The drift and the pause control are CSS in both; the difference is what moves the rows with the scroll. The preview runs either one: switch preview with above.

CostscrollAnimateCSS only
JavaScriptThe main entry, 11.9 KB gzipped, for one scrollAnimate per row.None.
The driftThe same CSS animation, with the same pause control.A CSS animation on the track, run by the browser off the main thread.
The scroll shiftA frame loop while each row is inside its window, writing its transform; outside the window it waits for the next scroll.A view() timeline: the browser moves it, no script.
BrowsersAll of them.Where animation-timeline: view() works: Chrome and Edge 115+, Safari 26+, Firefox 159+. Elsewhere the rows only drift.
Reduced motionscrollAnimate stops, and the CSS holds every row still and whole.No animation is declared at all.

Take the script when the rows must follow the scroll in every browser. Take CSS when no JavaScript is the point and rows that only drift are a good enough answer where scroll timelines are missing.

04Accessibility

A marquee everyone can read

Moving text is hard on some readers and invisible to others. The generated code handles four of them.

01

A pause control

Anything that moves on its own for more than five seconds next to other content needs a way to stop it (WCAG 2.2.2, Pause, Stop, Hide). The generated marquee has one: a Pause motion checkbox, and a rule that pauses every drift while it is checked, using :has(), so it works with no script at all. The scroll shift is not on that list: it moves only as the reader scrolls.

02

Screen readers hear it once

Each row holds its items twice, because the loop needs a copy to slide in behind the first. The copy carries aria-hidden="true", so a screen reader reads one list per row. The separators are CSS content with empty alt text, content: "✦" / "", so they are not read either.

03

Reduced motion

For readers whose system asks for less motion, nothing moves: no drift, no scroll shift. And because a still marquee would leave most of its words outside the row, the CSS hides the copy and wraps the items onto as many lines as they need, so all of the text is there to read.

04

Phones

Each row clips with overflow: clip, so however wide the track is, the page never scrolls sideways. clip, not hidden: hidden would make the row a scroll container, and the CSS version’s view() timeline would measure against it instead of the window.

055 options

The scrollAnimate call, option by option

01

props

required by default; the marquee sets { transform: ['translateX(0px)', 'translateX(-300px)'] }. The two ends of the shift, in px, reversed for a row that runs right. The engine interpolates each number in the first value’s unit, so both ends carry px: translateX(0) would come out unitless and be dropped.

02

trigger

{ start: 'top bottom', end: 'bottom top' } by default; the marquee sets { start: 'top bottom', end: 'top top' }. The default window runs until the row’s bottom leaves the top, one screen plus the row’s height. Ending at the row’s top makes the window exactly one screen, so the distance is per screen, whatever the text size.

03

easing

'ease-out' by default; the marquee sets 'linear'. scrollAnimate eases out by default, which would slow each row down as it nears the top. Linear keeps a pixel of scroll worth the same shift everywhere.

04

respectReducedMotion

true by default; the marquee sets left as it is. Under reduced motion it sets each row at its end and does not animate. The marquee’s CSS then holds the shift at none, wraps the items and hides the copy, so nothing moves and nothing is cut off.

05

scrollContainer

the window by default; the marquee sets only in the preview. A selector or an element that scrolls instead of the page. The preview passes its box, so a box’s height of scroll is one screen there.

Each call returns destroy(), refresh(), seek(p), pause(), resume(), replay() and getProgress(); the React component destroys its rows on unmount.

06Next step

Use it in your project

scrollAnimate is in the main entry, svg-scroll-draw, which the code above imports. The page the generator downloads loads that entry from jsDelivr as an ES module, pinned to 2.27.3, and the React project on StackBlitz installs it from npm. The CSS-only version needs no package at all.

077 questions

Scroll marquee questions

Q1

How do I make text move horizontally on scroll?

Put the text in a row with overflow: clip and move an element inside it with translateX as the page scrolls. With svg-scroll-draw that is scrollAnimate(el, { props: { transform: ['translateX(0px)', 'translateX(-300px)'] }, trigger: { start: 'top bottom', end: 'top top' }, easing: 'linear' }): the row slides 300px left over the screen of scroll it takes its top to cross the window. Swap the two values for a row that runs right. In CSS alone, run the same keyframes on animation-timeline: view() with animation-range: entry 0% exit 0%.

Q2

How do I make an infinite marquee in CSS?

Put the items in the track twice, make the track width: max-content, and animate it from translateX(0) to translateX(-50%) with linear timing and an infinite count. −50% is exactly one copy, so the second copy lands where the first started and the loop has no seam. Two details keep it seamless: give each copy half a gap of padding at each end, so the gap across the seam is a whole one, and make each copy at least as wide as the row (min-width: 100cqw, with the row as a container, and justify-content: space-around), so a short list never shows the row’s end.

Q3

What is a scroll velocity marquee, and can I make one?

A marquee that drifts on its own and runs faster while you scroll. Two nested elements do it: the inner one drifts with a CSS animation, the outer one moves with the scroll, and their transforms add up. Scroll quickly and the outer adds a lot of movement per second; stop and only the drift is left. The generator writes exactly that, with the scroll part in either scrollAnimate or a view() timeline.

Q4

Why does my marquee jump or show a gap at the end of each loop?

The usual causes: the two copies are not the same width (different items, or a gap set on the track between them rather than inside each copy), the loop moves by a fixed px value that is not the copy’s width, or the content is narrower than the row, so the track ends before the loop does. Animate to −50% of a track holding two identical copies, put the gap inside each copy as padding, and give each copy a min-width of the row’s width plus any scroll shift.

Q5

Is a scrolling marquee accessible?

It can be. Give the drift a pause control (WCAG 2.2.2), hide the duplicated copy from screen readers with aria-hidden="true", keep the separators out of the accessibility tree, and stop all motion under prefers-reduced-motion: reduce, showing every item instead. The generated code does all four.

Q6

How do I add a scroll marquee in React or Next.js?

Render the rows from an array, each with its two copies (the second with aria-hidden), import the CSS, and call scrollAnimate on each row’s shift element in a useEffect, destroying the instances in the cleanup. In the Next.js App Router the file needs 'use client'. The generator’s React tab writes the whole component, and opens it as a project in StackBlitz.

Q7

Can I still use the <marquee> tag?

Browsers still run it, but it is obsolete in the HTML standard: it cannot be paused, ignores reduced motion and does not follow the scroll. A CSS animation does everything it did, and the scroll-linked part on top.

More: CSS scroll-driven animations and scroll animation and reduced motion.