Copied!
Band

Marquee ticker

Endless horizontal text that pauses on hover, with speed, gap and direction controls.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

TextElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Marquee ticker" for my site using the Enrichr Library element at https://enrichr.dev/elements/marquee-ticker.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/marquee-ticker.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- text: Make it move • Make it count •
- speed: 90
- gap: 48
- direction: 1
- color: #ffffff

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/marquee-ticker.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-marquee"><div class="ef-marquee__track" aria-hidden="true"></div></div>

CSS

.ef-marquee { overflow: hidden; white-space: nowrap; width: 100%; }
.ef-marquee__track { display: inline-block; will-change: transform; font-size: clamp(2rem, 7vw, 5rem); font-weight: 700; line-height: 1.1; letter-spacing: -0.02em; }
.ef-marquee__item { display: inline-block; }
@media (prefers-reduced-motion: reduce) { .ef-marquee { white-space: normal; } .ef-marquee__track { transform: none !important; } }

JavaScript

// Marquee ticker: endless horizontal text. No dependencies.
// mount(root, { text, speed (px/s), gap (px), direction (1 or -1), color })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var track = root.querySelector(".ef-marquee__track");
  var opts = {}, raf = 0, last = 0, x = 0, unitW = 1, hover = false, visible = true, io = null;

  function build() {
    track.textContent = "";
    var unit = document.createElement("span");
    unit.className = "ef-marquee__item";
    unit.textContent = opts.text;
    unit.style.paddingRight = opts.gap + "px";
    track.appendChild(unit);
    unitW = Math.max(1, unit.offsetWidth);
    var copies = reduce ? 0 : Math.ceil((root.clientWidth * 2) / unitW) + 1;
    for (var i = 0; i < copies; i++) track.appendChild(unit.cloneNode(true));
    root.setAttribute("aria-label", opts.text);
    root.style.color = opts.color;
    track.style.transform = "translate3d(0,0,0)";
    x = 0;
  }
  function tick(t) {
    raf = requestAnimationFrame(tick);
    var dt = Math.min(0.05, (t - last) / 1000 || 0);
    last = t;
    if (hover || !visible) return;
    x -= opts.direction * opts.speed * dt;
    if (x <= -unitW) x += unitW;
    if (x > 0) x -= unitW;
    track.style.transform = "translate3d(" + x.toFixed(2) + "px,0,0)";
  }
  function onEnter() { hover = true; }
  function onLeave() { hover = false; }

  function update(next) {
    opts = Object.assign({ text: "Make it move", speed: 90, gap: 48, direction: 1, color: "#ffffff" }, opts, next || {});
    build();
  }
  update(o);
  if (!reduce) {
    root.addEventListener("pointerenter", onEnter);
    root.addEventListener("pointerleave", onLeave);
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(root); }
    raf = requestAnimationFrame(tick);
  }
  return {
    update: update,
    destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); root.removeEventListener("pointerenter", onEnter); root.removeEventListener("pointerleave", onLeave); }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Make it move • Make it count •","speed":90,"gap":48,"direction":1,"color":"#ffffff"});

How it works

The text is measured once, then repeated until two viewport widths are covered. One requestAnimationFrame loop moves a single track with translate3d, and when the offset reaches one item width it wraps back by exactly that width, so the join is never visible. The loop does nothing while the pointer is over it or the element is off screen.

Accessibility and performance

With prefers-reduced-motion the text is shown once and does not move. The repeated copies are hidden from screen readers and the container carries the text as its label, so it is read once.

Only transform changes, so there is no layout work per frame. The loop pauses off screen through IntersectionObserver. Roughly 1.5 KB of JavaScript.

SectionBand
TechniquesText animation
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Split text revealA headline whose words rise out of a mask one after another when it scrolls into view. Letter pop introEach letter overshoots its size and settles, staggered left to right, for a bold page intro. Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport.

FAQ

How do I stop the seam showing in a marquee?

Wrap the offset by exactly one item width, including its trailing gap, instead of resetting to zero. This element does that, so the loop is seamless at any speed.

Does it need GSAP?

No. It uses requestAnimationFrame and a CSS transform, so there is nothing to install.

Can I put logos in it instead of text?

Yes. Replace the text span with your own inline items; the measuring and wrapping work the same way.