Copied!
Text

Scroll text repetition

A headline repeated as stacked rows that slide sideways at different speeds and offsets as you scroll, alternating filled and outlined type.

← 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 "Scroll text repetition" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-text-repetition.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-text-repetition.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:
- accent: #e6ff55
- text: Keep scrolling
- rows: 6
- speed: 1
- outline: true

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/scroll-text-repetition.json.

Code

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

HTML

<div class="ef-str">
  <div class="ef-str__wrap">
    <div class="ef-str__scroller" tabindex="0" role="region" aria-label="Scroll to shift the repeated headline rows">
      <div class="ef-str__track">
        <div class="ef-str__stage"><div class="ef-str__rows"></div></div>
      </div>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-str { width: 100%; }
.ef-str__wrap { box-sizing: border-box; width: 100%; border-radius: 1rem; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-str__scroller { height: 22rem; overflow-y: auto; overflow-x: hidden; position: relative; outline: none; scrollbar-width: thin; scrollbar-color: var(--ef-acc, #e6ff55) transparent; }
.ef-str__scroller:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-str__track { height: 220%; position: relative; }
.ef-str__stage { position: sticky; top: 0; height: 22rem; overflow: hidden; display: flex; align-items: center; }
.ef-str__rows { width: 100%; display: flex; flex-direction: column; justify-content: center; }
.ef-str__row { display: block; white-space: nowrap; will-change: transform; transform: translate3d(var(--x, 0px), 0, 0); font-size: var(--ef-fs, 3.2rem); line-height: 0.98; font-weight: 900; letter-spacing: -0.03em; text-transform: uppercase; color: var(--ef-acc, #e6ff55); }
.ef-str__row.is-outline { color: transparent; -webkit-text-stroke: 1.5px var(--ef-acc, #e6ff55); }
@media (prefers-reduced-motion: reduce) { .ef-str__row { will-change: auto; transform: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Scroll text repetition: one headline repeated as stacked rows. Scrolling the element's own scroller slides each row sideways at a
// different speed and offset; rows alternate filled and outlined. Scroll progress (0-1) is mapped to a per-row translateX written
// via a CSS variable inside requestAnimationFrame. Reduced motion keeps the rows static. Works inside overflow:hidden containers.
// mount(root, { background, accent, text, rows (3-9), speed (0.2-2), outline (bool) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-str__wrap");
  var sc = root.querySelector(".ef-str__scroller");
  var stage = root.querySelector(".ef-str__stage");
  var rowsEl = root.querySelector(".ef-str__rows");
  var opts = {}, rows = [], raf = 0;

  function measure() {
    var h = Math.max(120, sc.clientHeight);
    stage.style.height = h + "px";
    var fs = Math.max(26, Math.min(76, (h / opts.rows) * 0.98));
    wrap.style.setProperty("--ef-fs", fs.toFixed(1) + "px");
  }
  function render() {
    raf = 0;
    var max = Math.max(1, sc.scrollHeight - sc.clientHeight);
    var p = sc.scrollTop / max;
    var w = sc.clientWidth;
    for (var i = 0; i < rows.length; i++) {
      if (reduce) { rows[i].style.setProperty("--x", "0px"); continue; }
      var dir = i % 2 ? 1 : -1;
      var mult = 0.35 + ((i * 37) % 11) / 10; // varied speed per row
      var base = (((i * 53) % 7) / 7 - 0.5) * w * 0.4; // varied offset per row
      var x = base + dir * (p - 0.5) * w * 1.1 * mult * opts.speed;
      rows[i].style.setProperty("--x", x.toFixed(1) + "px");
    }
  }
  function queue() { if (!raf) raf = requestAnimationFrame(render); }
  function build() {
    rowsEl.textContent = "";
    rows = [];
    for (var i = 0; i < opts.rows; i++) {
      var d = document.createElement("span");
      d.className = "ef-str__row" + (opts.outline && i % 2 ? " is-outline" : "");
      d.setAttribute("aria-hidden", i === 0 ? "false" : "true");
      var s = "";
      for (var k = 0; k < 4; k++) s += opts.text + " • ";
      d.textContent = s;
      rowsEl.appendChild(d);
      rows.push(d);
    }
    measure();
    queue();
  }
  function onScroll() { queue(); }
  function onResize() { measure(); queue(); }
  sc.addEventListener("scroll", onScroll, { passive: true });
  window.addEventListener("resize", onResize);
  var ro = null;
  if (window.ResizeObserver) { ro = new ResizeObserver(onResize); ro.observe(sc); }

  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", text: "Keep scrolling", rows: 6, speed: 1, outline: true }, opts, next || {});
    opts.rows = Math.max(3, Math.min(9, Math.round(opts.rows)));
    opts.speed = Math.max(0.2, Math.min(2, Number(opts.speed) || 1));
    opts.outline = opts.outline === true || opts.outline === "true";
    opts.text = String(opts.text || "Keep scrolling").slice(0, 40);
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    sc.setAttribute("aria-label", "Scroll to shift the repeated headline: " + opts.text);
    if (prev.rows !== opts.rows || prev.text !== opts.text || prev.outline !== opts.outline) build(); else queue();
  }
  update(o);
  return {
    update: update,
    destroy: function () {
      sc.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onResize);
      if (ro) ro.disconnect();
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","text":"Keep scrolling","rows":6,"speed":1,"outline":"true"});

How it works

The element owns a scrollable box with a tall track inside it and a sticky stage that stays in view. On scroll, the script reads scrollTop as a 0 to 1 progress value and, inside one requestAnimationFrame, writes a --x offset to each row. Every row gets its own direction (alternating left and right), speed multiplier and starting offset derived from its index, so the stack fans out and crosses over as you scroll. Rows are the same headline repeated, with every other row switched to an outlined style using text stroke.

Accessibility and performance

The scroller is focusable, so keyboard users can scroll it with the arrow keys, Page Up and Page Down. Only the first row is exposed to screen readers; the repeats are aria-hidden so the headline is not announced several times. With reduced motion on, the rows stay still and the scroll does nothing visually. Outlined text has lower contrast than filled text, so keep it decorative.

One passive scroll listener queues a single requestAnimationFrame, which writes one CSS variable per row. Rows animate only by transform, and a ResizeObserver handles size changes. The listeners, observer and any pending frame are removed by destroy().

SectionText
TechniquesText animation, Scroll-driven, Parallax and depth
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byOn-Scroll Text Repetition Animation by Codrops on Codrops. 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.

Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport. Scroll velocity skewText that leans and splits into two colour fringes in proportion to how fast the page scrolls. Scroll spring headingAn oversized heading slides sideways with scroll progress through a spring, lagging and settling.

FAQ

Does it need the page to scroll?

No. It listens to its own scroll container, so it works inside a clipped card or modal. To drive it from page scroll instead, read window scroll progress and use the same per-row formula.

How do I change how far the rows travel?

Use the speed control, or edit the multiplier in the render loop. The per-row variation comes from the row index, so it is stable between renders.

Can the rows use a different font?

Yes. Set font-family on .ef-str__row. Heavy, tightly spaced uppercase faces read best at this size.