GalleryFree

Basic Marquee

A line of messages that scrolls right to left at a steady speed, endless and gap-free at any width, and stops on hover or with a click on Pause.

CSSMinimal

Updated

  • Welcome to Effect.Labs
  • Premium Web Effects
  • Modern CSS Animations
  • Infinite Scrolling
Hover or click the scene to interact

This effect is free — the Gallery category contains 44 effects total, including 6 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

INFO
  • Nordic pool closed for maintenance until 14 November
  • Open from 7 am, Monday to Friday
  • Thursday aqua fitness: fully booked
  • Swim cap required

Today

Last entry 45 minutes before closing

  • Main pool · 25 m7:00 – 20:30
  • Play pool10:00 – 18:00
  • Nordic poolClosed
  • Paddling pool10:00 – 18:00

① Practical info for a swimming pool — a thin line under the header

WhenOn the Brévalie pool home page, a 32 px yellow strip under the menu runs the day's news: Nordic pool closed, opening hours, aqua fitness full. Below it, today's hours and the prices stay in place.
WhyFour short, frequently changing notices fit on one line without pushing the page down. The low speed (40 px/s) lets you read a whole sentence before it leaves, and the Pause button, shrunk to 22 px, sits at the end of the strip.
Settingsdata-speed="40", --bmq-height: auto, --bmq-padding: 0, --bmq-bg: transparent, --bmq-color: #5b4100, --bmq-size: 12px, --bmq-space: 2.2rem, --bmq-button-size: 22px; a dot added before each <li> by the page's stylesheet.
  • Lemon
  • Elderflower
  • Grapefruit
  • Ginger
  • Rhubarb
  • Mint

Craft lemonade

Six flavours, little sugar, plenty of bubbles.

Real fruit, cane sugar, nothing else.

33 cl returnable bottleCrates of 12Delivered within 48 h

② Flavour names, very large, behind the bottle — a lemonade maker's hero

WhenOn the Corlidane lemonade home page, the six flavours (lemon, elderflower, grapefruit, ginger, rhubarb, mint) pass in 92 px capitals behind the bottle drawn on the right, between the menu and the headline “Six flavours, little sugar, plenty of bubbles”.
WhyThe band works as decoration: it gives the background colour and movement, and announces the whole range with no list to read. Its hue, close to the background, never competes with the headline; the bottle placed in front adds depth. The Pause button, set at the end of the band, stays within reach.
Settingsdata-speed="45", --bmq-size: 92px, --bmq-space: 30px, --bmq-color: #efb725, --bmq-bg: transparent, --bmq-height: auto, --bmq-padding: 0, --bmq-button-size: 22px; capitals and weight 900 set by the page stylesheet, bottle placed above the band.

New video

“Nights without a map”

Taken from the album out on 6 March. Tour across France this spring.

③ Tour dates between two sections — every date is a link

WhenOn the site of the band Vellumbre, an orange strip separates the new video from the shop; it runs the five March dates, each with its “Tickets” link. Resting the mouse on it stops the band long enough to click.
WhyA clear break between two sections that does a job: you see the cities in passing and click without hunting for the Tour page. With the keyboard, Tab visits the five links once each, band held still.
Settingsdata-speed="55", --bmq-height: 42px, --bmq-size: 13px, --bmq-space: 12px, --bmq-color: #160b08; in each <li>, an <a href> styled as an outlined pill, orange background set by the section.

How it works

You write your messages once, each in an <li> of the .bmq-group list. On load, the script measures that list and the .bmq-viewport window, then appends as many copies as it takes to cover the window plus one: one copy on a phone, two in a 1,900 px area with the demo text (about 1,240 px long). The old version doubled the text once and for all and left an empty stretch on the right as soon as the area was wider than the text.

The bmq-scroll CSS animation moves the track by exactly one list length, then starts over: the next copy has taken the original's place to the pixel, so the seam never shows. The script derives the duration from data-speed (85 px per second by default): a text twice as long takes twice as long, it does not rush through twice as fast. data-direction="right" reverses the direction.

A ResizeObserver recounts when the area or the text changes size (phone rotation, font loaded late), and a MutationObserver rebuilds the copies if a script edits the list. Each band is handled on its own: several bands on one page each keep their speed and their button.

Accessibility

  • Pause: the round button (aria-pressed) stops and restarts the band with a mouse, a finger or the keyboard; hovering the band also stops it while you read. A band that moves for more than 5 seconds must be stoppable (WCAG 2.2.2).
  • Screen readers: only the original list is read, once; the copies carry aria-hidden="true". Measured before the repair: every message was read twice.
  • Links in the band: with the keyboard, Tab only visits the links of the original list (those in the copies get tabindex="-1"). As soon as a link has focus, the band returns to its start, stays still and brings that link fully into view; it moves again once focus leaves.
  • Reduced motion: with prefers-reduced-motion: reduce, nothing scrolls by itself, the copies and the button go away, and the line can be scrolled by hand (finger, trackpad, Shift + wheel, thin bar) to read the end; when it overflows, it also takes keyboard focus (Tab) and scrolls with the arrow keys. Without JavaScript, it stays still and scrollable by hand.
  • Colour: indigo #6366f1 on the #1a1a24 background gives a 3.9:1 contrast, above the 3:1 threshold allowed for 24 px bold text but too low for small text; on a light background, change --bmq-color.

Browser compatibility

CSS and 4.5 KB of commented JavaScript, no dependency. A CSS animation whose offset comes from a variable (var(--bmq-shift) inside @keyframes), ResizeObserver (Chrome 64, Firefox 69, Safari 13.1), MutationObserver, prefers-reduced-motion (Chrome 74, Firefox 63, Safari 10.1). Measured in Chromium 151, Firefox 153 and WebKit 26.5: no gap at 390, 800 and 1,900 px, two independent bands, script pasted twice without extra copies, 0 errors.

Chrome 74+✓ Full (measured)
Firefox 69+✓ Full (measured)
Safari 13.1+✓ Full (WebKit engine measured)
Edge 79+✓ Full (Chromium engine)
Mobile iOS 13.4+✓ Full (WebKit engine measured)
Android Chrome 74+✓ Full (Chromium engine)

Without JavaScript, the list stays still, shown once and scrollable by hand. Without ResizeObserver (Safari before 13.1), copies are only recounted when the window is resized. The thin scrollbar of reduced-motion mode (scrollbar-width) only exists in Chrome 121+, Firefox 64+ and Safari 18.2+; elsewhere the regular bar shows.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="fx-0187-root" data-speed="85" data-direction="left">
  <div class="bmq-band">
    <div class="bmq-viewport">
      <div class="bmq-track">
        <ul class="bmq-group" role="list">
          <li>Welcome to Effect.Labs</li>
          <li>Premium Web Effects</li>
          <li>Modern CSS Animations</li>
          <li>Infinite Scrolling</li>
        </ul>
      </div>
    </div>
    <button type="button" class="bmq-toggle" aria-pressed="false" aria-label="Pause"></button>
  </div>
</div>
CSS
.fx-0187-root,
.fx-0187-root *,
.fx-0187-root *::before,
.fx-0187-root *::after {
  box-sizing: border-box;
}

/* Settings: variables below; speed (px/s) and direction in the data-speed and data-direction attributes of the HTML */
.fx-0187-root {
  --bmq-height: 250px;                        /* frame height (auto: the band height) */
  --bmq-bg: rgb(26, 26, 36);                  /* frame background */
  --bmq-color: #6366f1;                       /* text colour */
  --bmq-size: 1.5rem;                         /* text size */
  --bmq-space: 4rem;                          /* space between two items */
  --bmq-button: rgba(255, 255, 255, .8);      /* Pause button colour */
  --bmq-padding: 1rem 0;                      /* frame inner padding */
  --bmq-button-size: 2rem;                    /* Pause button diameter */
  display: flex;
  align-items: center;
  width: 100%;
  min-height: var(--bmq-height);
  padding: var(--bmq-padding);
  overflow: hidden;
  position: relative;
  background: var(--bmq-bg);
  color: #fff;
  font-family: sans-serif;
}

.bmq-band {
  display: flex;
  align-items: center;
  gap: .75rem;
  width: 100%;
  min-width: 0;
}

.bmq-viewport {
  flex: 1;
  min-width: 0;
  overflow: hidden;
}

.bmq-track {
  display: flex;
  width: max-content;
}

.bmq-group {
  display: flex;
  align-items: center;
  flex-shrink: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--bmq-size);
  font-weight: 600;
  color: var(--bmq-color);
}

.bmq-group > li {
  margin: 0 calc(var(--bmq-space) / 2);
  white-space: nowrap;
}

/* Scrolling: started by the script once the track is filled (bmq-on class) */
.fx-0187-root.bmq-on .bmq-track {
  animation-name: bmq-scroll;
  animation-duration: var(--bmq-duration, 15s);
  animation-timing-function: linear;
  animation-iteration-count: infinite;
  will-change: transform;
}

.fx-0187-root[data-direction="right"] .bmq-track {
  animation-direction: reverse;
}

@keyframes bmq-scroll {
  from {
    transform: translateX(0);
  }

  to {
    transform: translateX(var(--bmq-shift));
  }
}

/* Pause: hovering the band or the Pause button */
.bmq-viewport:hover .bmq-track,
.fx-0187-root.bmq-paused .bmq-track {
  animation-play-state: paused;
}

/* Keyboard focus inside the band (bmq-focus class): band back to its start and still, the browser scrolls to the link */
.fx-0187-root.bmq-focus .bmq-track {
  animation: none;
}

/* Pause / Play button (remove it from the HTML if the band is short and purely decorative) */
.bmq-toggle {
  flex: none;
  position: relative;
  width: var(--bmq-button-size);
  height: var(--bmq-button-size);
  margin: 0 1rem 0 0;
  padding: 0;
  border: 1px solid currentColor;
  border-radius: 50%;
  background: transparent;
  color: var(--bmq-button);
  cursor: pointer;
}

.bmq-toggle::before,
.bmq-toggle::after {
  content: "";
  position: absolute;
  top: 50%;
  width: 3px;
  height: 10px;
  margin-top: -5px;
  border-radius: 1px;
  background: currentColor;
}

.bmq-toggle::before {
  left: calc(50% - 4px);
}

.bmq-toggle::after {
  left: calc(50% + 1px);
}

.bmq-toggle[aria-pressed="true"]::before {
  left: calc(50% - 3px);
  width: 0;
  height: 0;
  margin-top: -6px;
  border-style: solid;
  border-width: 6px 0 6px 10px;
  border-color: transparent transparent transparent currentColor;
  border-radius: 0;
  background: none;
}

.bmq-toggle[aria-pressed="true"]::after {
  display: none;
}

.bmq-toggle:hover {
  background: rgba(127, 127, 127, .18);
}

.bmq-toggle:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

/* Without the script: still band, scrollable by hand */
.fx-0187-root:not(.bmq-on) .bmq-viewport {
  overflow-x: auto;
}

.fx-0187-root:not(.bmq-on) .bmq-toggle {
  display: none;
}

/* Reduced motion: nothing moves by itself, the content stays readable and scrollable by hand */
@media (prefers-reduced-motion: reduce) {
  .fx-0187-root.bmq-on .bmq-track {
    animation: none;
  }

  .fx-0187-root .bmq-viewport {
    overflow-x: auto;
    scrollbar-width: thin;
  }

  .fx-0187-root .bmq-viewport:focus-visible {
    outline: 2px solid currentColor;
    outline-offset: -2px;
  }

  .fx-0187-root .bmq-clone,
  .fx-0187-root .bmq-toggle {
    display: none;
  }
}
JavaScript (fx-0187)
(function () {
  'use strict';
  // fx-0187 — Basic Marquee : the content is written once, the script copies it to fill the band
  var ROOT = '.fx-0187-root';
  var VERTICAL = false;
  var DEFAULT_SPEED = 85; // pixels per second
  var FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';

  // Decorative copy: hidden from screen readers and out of the keyboard order
  function copyOf(group) {
    var copy = group.cloneNode(true);
    copy.classList.add('bmq-clone');
    copy.setAttribute('aria-hidden', 'true');
    copy.removeAttribute('id');
    copy.querySelectorAll('[id]').forEach(function (el) { el.removeAttribute('id'); });
    copy.querySelectorAll(FOCUSABLE).forEach(function (el) { el.setAttribute('tabindex', '-1'); });
    return copy;
  }

  function setup(root) {
    if (root.bmqReady) return; // script pasted twice
    root.bmqReady = true;
    var reduced = window.matchMedia('(prefers-reduced-motion: reduce)');

    var bands = [];
    root.querySelectorAll('.bmq-viewport').forEach(function (viewport) {
      var track = viewport.querySelector('.bmq-track');
      var group = track && track.querySelector('.bmq-group');
      if (!group) return;
      var holder = viewport.closest('[data-speed]');
      bands.push({ viewport: viewport, track: track, group: group, holder: holder && root.contains(holder) ? holder : root });
    });
    if (!bands.length) return;

    // Enough copies to cover the visible area + one coming in; shift = one group
    function fill(band) {
      var box = band.group.getBoundingClientRect();
      var size = VERTICAL ? box.height : box.width;
      var room = VERTICAL ? band.viewport.clientHeight : band.viewport.clientWidth;
      if (!size) return;
      var copies = band.track.children.length;
      var needed = Math.ceil(room / size) + 1;
      for (; copies < needed; copies++) band.track.appendChild(copyOf(band.group));
      var speed = parseFloat(band.holder.getAttribute('data-speed')) || DEFAULT_SPEED;
      band.track.style.setProperty('--bmq-shift', -size + 'px');
      band.track.style.setProperty('--bmq-duration', (size / speed) + 's');
      // Reduced motion: the band scrolls by hand, so also with the keyboard (tabindex) when it overflows
      var over = VERTICAL ? band.viewport.scrollHeight > band.viewport.clientHeight + 1 : band.viewport.scrollWidth > band.viewport.clientWidth + 1;
      if (reduced.matches && over) band.viewport.setAttribute('tabindex', '0');
      else band.viewport.removeAttribute('tabindex');
      root.classList.add('bmq-on');
    }

    function fillAll() { bands.forEach(fill); }
    fillAll();

    // Content changed later (translated text, new item): the copies are rebuilt
    if ('MutationObserver' in window) {
      bands.forEach(function (band) {
        new MutationObserver(function () {
          band.track.querySelectorAll('.bmq-clone').forEach(function (copy) { copy.remove(); });
          fill(band);
        }).observe(band.group, { childList: true, subtree: true, characterData: true });
      });
    }
    if ('ResizeObserver' in window) {
      var observer = new ResizeObserver(fillAll);
      bands.forEach(function (band) {
        observer.observe(band.viewport);
        observer.observe(band.group);
      });
    } else {
      window.addEventListener('resize', fillAll);
    }

    // Keyboard focus on a link in the band: still band (bmq-focus) with the link brought into view, put back when focus leaves
    root.addEventListener('focusin', function (event) {
      var viewport = event.target.closest('.bmq-viewport');
      root.classList.toggle('bmq-focus', !!viewport);
      if (!viewport) return;
      var view = viewport.getBoundingClientRect();
      var item = event.target.getBoundingClientRect();
      if (VERTICAL) {
        if (item.bottom > view.bottom) viewport.scrollTop += item.bottom - view.bottom;
        if (item.top < view.top) viewport.scrollTop -= view.top - item.top;
      } else {
        if (item.right > view.right) viewport.scrollLeft += item.right - view.right;
        if (item.left < view.left) viewport.scrollLeft -= view.left - item.left;
      }
    });
    root.addEventListener('focusout', function () {
      root.classList.remove('bmq-focus');
      if (!reduced.matches) bands.forEach(function (band) { band.viewport.scrollLeft = 0; band.viewport.scrollTop = 0; });
    });

    var toggle = root.querySelector('.bmq-toggle');
    if (toggle) {
      toggle.addEventListener('click', function () {
        var paused = toggle.getAttribute('aria-pressed') !== 'true';
        toggle.setAttribute('aria-pressed', paused ? 'true' : 'false');
        root.classList.toggle('bmq-paused', paused);
      });
    }
  }

  function init() {
    document.querySelectorAll(ROOT).forEach(setup);
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
  else init();
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Content (HTML — the <li> of .bmq-group) 4 demo texts Write each message once: text, links, icons or images. The script makes the copies, and rebuilds them if the content changes later.
data-speed (on the root) 85 Speed in pixels per second, the same whatever the text length. 30 to 50 for sentences to read, 80 to 120 for decoration.
data-direction left right scrolls from left to right.
--bmq-color / --bmq-size #6366f1 / 1.5rem Colour and size of the band text.
--bmq-space 4rem Gap between two messages, the same at the seam between two copies.
--bmq-height / --bmq-padding / --bmq-bg 250px / 1rem 0 / rgb(26, 26, 36) Demo frame. For a band placed inside a page: auto, 0 and transparent.
--bmq-button / --bmq-button-size rgba(255, 255, 255, .8) / 2rem Colour and diameter of the Pause button. Only remove the <button> from the HTML for a short, decorative band.

FAQ

Why is there no longer an empty stretch on the right on a wide screen?

The text used to be written twice in the HTML and the animation shifted it by 50%: if the area was wider than the text (about 1,240 px here), the end of the second copy arrived before the right edge. The script now counts copies from the real width, and recounts on every resize.

How do I add a message without reloading the page?

Add an <li> to the .bmq-group list (with append(), for instance): the script notices, removes the old copies, makes new ones and recomputes the duration to keep the same speed.

Does the band stop on a phone?

The Pause button is the reliable way. Measured in Chromium in touch mode: a tap on the band also stops it, because the browser simulates a hover until the next tap elsewhere on the page; not every mobile browser does this. Swiping vertically over the band always scrolls the page, and tapping a link in the band opens it, as anywhere else.