Copied!
Band

Double ticker band

Two stacked bands of repeated words that slide past on their own endless loops, at different speeds and in either direction.

← Library

Element · Vanilla JS + CSSUpdated November 6, 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 "Double ticker band" for my site using the Enrichr Library element at https://enrichr.dev/elements/double-ticker-band.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/double-ticker-band.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:
- top: Capture it
- bottom: Rebuild it
- separator: •
- topSpeed: 30
- bottomSpeed: 14
- topDirection: left
- bottomDirection: left
- size: 1.2
- gap: 60
- fade: 0
- topBg: #0c1e18
- topInk: #e6ff55
- bottomBg: #fbf8f1
- bottomInk: #0c1e18
- background: #fbf8f1

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/double-ticker-band.json.

Code

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

HTML

<div class="ef-dtk">
  <div class="ef-dtk__rows" tabindex="0" role="group" aria-label="Two scrolling lines of text. Focus here to pause them.">
    <div class="ef-dtk__row ef-dtk__row--a"><p class="ef-dtk__sr"></p><div class="ef-dtk__track" aria-hidden="true"></div></div>
    <div class="ef-dtk__row ef-dtk__row--b"><p class="ef-dtk__sr"></p><div class="ef-dtk__track" aria-hidden="true"></div></div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-dtk { --ef-dtk-page: #fbf8f1; --ef-dtk-a-bg: #0c1e18; --ef-dtk-a-ink: #e6ff55; --ef-dtk-b-bg: #fbf8f1; --ef-dtk-b-ink: #0c1e18; --ef-dtk-size: 1.2rem; --ef-dtk-gap: 60px; --ef-dtk-fade: 0px; position: relative; width: 100%; min-height: 20rem; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; overflow: hidden; border-radius: 1rem; background: var(--ef-dtk-page); font-family: 'Parkinsans', 'Space Grotesk', sans-serif; }
.ef-dtk .ef-dtk__rows { width: 100%; overflow: hidden; outline: none; }
.ef-dtk .ef-dtk__rows:focus-visible { box-shadow: 0 0 0 3px var(--ef-dtk-a-ink), 0 0 0 6px var(--ef-dtk-a-bg); }
.ef-dtk .ef-dtk__row { position: relative; overflow: hidden; white-space: nowrap; -webkit-mask-image: var(--ef-dtk-mask, none); mask-image: var(--ef-dtk-mask, none); }
.ef-dtk .ef-dtk__row--a { padding: 12px 0; background: var(--ef-dtk-a-bg); color: var(--ef-dtk-a-ink); border-top: 3px solid var(--ef-dtk-a-bg); border-bottom: 3px solid var(--ef-dtk-a-bg); }
.ef-dtk .ef-dtk__row--b { padding: 10px 0; background: var(--ef-dtk-b-bg); color: var(--ef-dtk-b-ink); }
.ef-dtk .ef-dtk__track { display: flex; width: max-content; will-change: transform; }
.ef-dtk .ef-dtk__item { flex: none; margin: 0; padding: 0 var(--ef-dtk-gap) 0 0; font-size: var(--ef-dtk-size); font-weight: 800; line-height: 1.15; letter-spacing: 1px; text-transform: uppercase; opacity: 1; }
.ef-dtk .ef-dtk__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Double ticker band: two stacked full-width bands of repeated words, each on its own endless loop. Each band holds one
// short line repeated eight times as a set, the set is doubled, and the track slides by exactly half its width on a linear
// loop, so the join never shows. The top band takes 30 seconds per set and the bottom one 14, as in the original, so they
// drift past each other at different speeds; either can run the other way. On a wide screen extra sets are added and the
// loop time grows with them, so the words keep the same pace. The loops run as Web Animations on the compositor.
// Reduced motion shows the words still. The loops pause off screen, in hidden tabs, and while the band has keyboard focus.
// mount(root, { top (text), bottom (text), separator, topSpeed (s per set of 8), bottomSpeed (s per set of 8),
//               topDirection ("left" or "right"), bottomDirection, size (rem), gap (px), fade (edge fade, px),
//               topBg, topInk, bottomBg, bottomInk, background })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-dtk");
  var group = root.querySelector(".ef-dtk__rows");
  var SET = 8; // copies per set, as in the original
  var rows = [].map.call(root.querySelectorAll(".ef-dtk__row"), function (el, i) {
    return { el: el, track: el.querySelector(".ef-dtk__track"), sr: el.querySelector(".ef-dtk__sr"), key: i ? "bottom" : "top", anim: null, dur: 0, dir: "", sig: "" };
  });
  var opts = {}, io = null, ro = null, visible = true, focused = false, destroyed = false, lastW = 0;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function item(text) { var s = document.createElement("span"); s.className = "ef-dtk__item"; s.textContent = text; return s; }
  function frames(dir) {
    var a = { transform: "translate3d(0,0,0)" }, b = { transform: "translate3d(-50%,0,0)" };
    return dir === "right" ? [b, a] : [a, b];
  }
  function paused() { return !visible || focused || document.hidden; }

  // Start (or restart) one band's loop, keeping its current place so a live change never jumps.
  function play(r, sec, dir) {
    if (reduce || !r.track.animate) return;
    var dur = Math.max(0.5, sec) * 1000, p = 0;
    if (r.anim) {
      var ct = Number(r.anim.currentTime) || 0;
      p = r.dur ? (ct % r.dur) / r.dur : 0;
      if (r.dir && r.dir !== dir) p = 1 - p;
      r.anim.cancel();
    }
    r.anim = r.track.animate(frames(dir), { duration: dur, iterations: Infinity, easing: "linear" });
    r.anim.currentTime = p * dur;
    r.dur = dur; r.dir = dir;
    if (paused()) r.anim.pause();
  }
  // Fill a band: one set of eight must cover the width; add sets until it does, and scale the loop time to match.
  function build(r) {
    var text = String(opts[r.key] || "").trim() || " ";
    var label = opts.separator ? text + " " + opts.separator : text;
    var sig = label + "|" + opts.size + "|" + opts.gap;
    r.sr.textContent = text;
    var probe = item(label);
    r.track.textContent = "";
    r.track.appendChild(probe);
    var w = probe.getBoundingClientRect().width || 120;
    var k = Math.max(1, Math.ceil((box.clientWidth || 800) / (w * SET)));
    var n = SET * k * 2, frag = document.createDocumentFragment();
    for (var i = 0; i < n; i++) frag.appendChild(item(label));
    r.track.textContent = "";
    r.track.appendChild(frag);
    r.k = k; r.sig = sig;
  }
  function run(r) { play(r, num(opts[r.key + "Speed"], r.key === "top" ? 30 : 14) * r.k, opts[r.key + "Direction"] === "right" ? "right" : "left"); }
  function sync() { rows.forEach(function (r) { if (r.anim) { if (paused()) r.anim.pause(); else r.anim.play(); } }); }

  function update(partial) {
    opts = Object.assign({
      top: "Capture it", bottom: "Rebuild it", separator: "•", topSpeed: 30, bottomSpeed: 14, topDirection: "left", bottomDirection: "left",
      size: 1.2, gap: 60, fade: 0, topBg: "#0c1e18", topInk: "#e6ff55", bottomBg: "#fbf8f1", bottomInk: "#0c1e18", background: "#fbf8f1"
    }, opts, partial || {});
    opts.size = Math.max(0.5, num(opts.size, 1.2)); opts.gap = Math.max(0, num(opts.gap, 60)); opts.fade = Math.max(0, num(opts.fade, 0));
    var s = box.style;
    s.setProperty("--ef-dtk-page", opts.background);
    s.setProperty("--ef-dtk-a-bg", opts.topBg); s.setProperty("--ef-dtk-a-ink", opts.topInk);
    s.setProperty("--ef-dtk-b-bg", opts.bottomBg); s.setProperty("--ef-dtk-b-ink", opts.bottomInk);
    s.setProperty("--ef-dtk-size", opts.size + "rem"); s.setProperty("--ef-dtk-gap", opts.gap + "px");
    s.setProperty("--ef-dtk-mask", opts.fade > 0 && !reduce ? "linear-gradient(to right, transparent 0, #000 " + opts.fade + "px, #000 calc(100% - " + opts.fade + "px), transparent 100%)" : "none");
    if (destroyed) return;
    rows.forEach(function (r) {
      var text = String(opts[r.key] || "").trim() || " ";
      if ((opts.separator ? text + " " + opts.separator : text) + "|" + opts.size + "|" + opts.gap !== r.sig) build(r);
      run(r);
    });
    lastW = box.clientWidth;
  }
  function refit() { if (destroyed) return; rows.forEach(function (r) { r.sig = ""; build(r); run(r); }); lastW = box.clientWidth; }
  function onFocus() { focused = true; sync(); }
  function onBlur() { focused = false; sync(); }
  function onVis() { sync(); }

  update(o);
  group.addEventListener("focus", onFocus);
  group.addEventListener("blur", onBlur);
  document.addEventListener("visibilitychange", onVis);
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; sync(); }); io.observe(box); }
  if ("ResizeObserver" in window) {
    ro = new ResizeObserver(function () { var w = box.clientWidth; if (Math.abs(w - lastW) > 24) refit(); });
    ro.observe(box);
  }
  if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { refit(); });
  return {
    update: update,
    destroy: function () {
      destroyed = true;
      rows.forEach(function (r) { if (r.anim) r.anim.cancel(); r.anim = null; });
      group.removeEventListener("focus", onFocus); group.removeEventListener("blur", onBlur);
      document.removeEventListener("visibilitychange", onVis);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"top":"Capture it","bottom":"Rebuild it","separator":"•","topSpeed":30,"bottomSpeed":14,"topDirection":"left","bottomDirection":"left","size":1.2,"gap":60,"fade":0,"topBg":"#0c1e18","topInk":"#e6ff55","bottomBg":"#fbf8f1","bottomInk":"#0c1e18","background":"#fbf8f1"});

How it works

Each band holds one short line with its separator, repeated eight times as a set, and the set is doubled. The track slides from 0 to minus 50 percent of its own width on a linear loop, which moves it by exactly one set, so the start and end frames are identical and the join never shows. As in the original, the top band takes 30 seconds per set and the bottom band 14, so the two lines drift past each other at different speeds. If one set is narrower than the element, more sets are added to each half and the loop time is multiplied by the same number, so the words keep the same pace on any screen. Each loop is a Web Animation, so it runs on the compositor, and a live change of speed or direction keeps the band where it is instead of jumping. The edge fade is an optional CSS mask on each band.

Accessibility and performance

Each line is read once from a visually hidden paragraph; the moving copies are hidden from screen readers. The pair of bands can take keyboard focus, and both loops stop while it has focus, with a visible focus ring. With prefers-reduced-motion the words stay still.

Only transform animates, as a Web Animation on the compositor, so there is no JavaScript per frame and no layout work. Text is measured once, again after web fonts load, and again only when the width changes by more than 24 pixels. Both loops pause off screen through IntersectionObserver and in hidden tabs.

SectionBand
TechniquesText animation
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byDouble Ticker by Enrichr on CodePen. Original code, written from scratch.

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.

Marquee tickerEndless horizontal text that pauses on hover, with speed, gap and direction controls. 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.

FAQ

How is this different from the Marquee ticker element?

Marquee ticker is one big line of text with no background that pauses on hover. This one is two coloured bands stacked together, each with its own line, speed and direction, sized as a slim strip to sit between page sections.

Can the two bands run in opposite directions?

Yes. Set the bottom direction to Right. The original pen runs both to the left at different speeds, so that is the default.

Why are speeds given per 8 repeats?

That is how the original is built: eight copies of the line slide by in 30 or 14 seconds. Keeping that unit means the pace of the words stays the same even when extra copies are added to fill a wide screen.

Does it need GSAP or a marquee library?

No. Each band is one Web Animations call that moves a doubled track by half its width, the same thing the original does with a CSS keyframe animation.