Scroll Velocity Marquee — Free HTML CSS JS Snippet

Scroll Velocity Marquee · Animations · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Scroll-velocity reactive
Speed scales with frame-to-frame scrollY delta.
Momentum skew
skewX leans the letters with the scroll, clamped to ±18°.
Exponential ease-out
Velocity decays by 0.9 each frame after you stop.
Baseline drift
Rows keep moving even when the page is still.
Opposing rows
data-dir scrolls and skews the two rows opposite ways.
Seamless loop
Width-based wrapping with no visible seam.
Frame-rate independent
A dt factor keeps speed consistent across displays.
Outline + fill type
-webkit-text-stroke alternates hollow and solid words.

About this UI Snippet

Scroll Velocity Marquee — Speed and Skew Driven by Scrolling

Screenshot of the Scroll Velocity Marquee snippet rendered live

The scroll-velocity marquee is the kinetic-typography effect seen on award-winning agency and portfolio sites: rows of giant words drift slowly on their own, but the moment you scroll, they surge in the scroll direction and skew with the momentum — then ease back to a calm baseline when you stop. This snippet implements the whole behavior in plain HTML, CSS, and vanilla JavaScript with no scroll library.

Measuring scroll velocity

A passive scroll listener computes velocity as the simple frame-to-frame delta of window.scrollY: how many pixels the page moved since the last event. Scrolling down fast yields a large positive value; scrolling up yields a negative one. This raw velocity feeds the animation, so the marquee responds to both how fast and which way you scroll. The listener is marked { passive: true } so it never blocks the browser's scrolling.

A baseline drift plus a boost

The animation runs in a continuous requestAnimationFrame loop. Each frame, the track moves by a constant BASE speed so it always drifts even when the page is still, plus a boost proportional to the absolute scroll velocity, plus a direct velocity term that shoves it in the scroll direction. Crucially, the stored velocity is multiplied by 0.9 every frame, so after you stop scrolling it decays exponentially toward zero — the surge eases out naturally instead of stopping dead.

The skew that sells the motion

Speed alone looks mechanical; the effect comes alive because the track also applies skewX. The skew is computed from velocity and clamped to ±18° so fast scrolls visibly lean the letters like they're being dragged through the air, while a still page sits at 0°. Each row's skew is multiplied by its direction so the two rows lean opposite ways, reinforcing the sense of momentum.

Opposing rows

The two rows read a data-dir of 1 and -1, so they scroll and skew in opposite directions. This counter-motion is what makes the section feel dynamic rather than like a single conveyor belt. The same loop drives both; only the direction multiplier differs.

Seamless looping and frame-rate independence

Each track is filled with several repetitions of the word list, and its loop width is measured as scrollWidth / 2. When the offset passes that width in either direction, it wraps by adding or subtracting the width, landing on an identical frame so there's no visible seam. A dt factor normalizes each frame against a 16.67ms baseline (and is capped at 3) so the speed stays consistent across refresh rates and survives the occasional dropped frame.

Type styling

The words alternate between outline and filled styles using -webkit-text-stroke — odd items are hollow purple outlines, even items are solid lavender — with a cyan dot separating each. Horizontal mask-image gradients fade both ends so words enter and exit softly. All of this is pure CSS; the JavaScript only sets transform.

Customizing it

Tune BASE for a faster or slower resting drift, raise the 0.25 boost factor for a more violent reaction, change the 0.9 decay for a longer or snappier ease-out, or widen the skew clamp for more dramatic lean. Swap the WORDS array for your own slogan, add more rows with alternating data-dir, and recolor the stroke and fill. Pair it with a logo marquee or a lamp header for a bold landing section.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSTwo rows of giant words drift slowly in opposite directions.
  2. 2
    Scroll the pageThe words surge in the scroll direction and skew with the momentum.
  3. 3
    Stop scrollingThe boost decays and the rows ease back to a calm baseline drift.
  4. 4
    Scroll upVelocity goes negative and the rows reverse and lean the other way.
  5. 5
    Edit the wordsReplace the WORDS array with your own slogan.
  6. 6
    Tune the feelAdjust BASE, the boost factor, decay, and skew clamp.

Real-world uses

Common Use Cases

Agency landing pages
A kinetic band above a feature tabs showcase.
Portfolio sites
Between a portfolio hero and the work grid.
Brand statements
Loop slogans like a giant marquee with motion.
Event microsites
Pair with a lamp header for drama.
Product launches
Reinforce a tagline near an animated gradient CTA.
Kinetic type demos
A reference for scroll-driven velocity and skew.

Got questions?

Frequently Asked Questions

A passive scroll listener stores the difference between the current and previous window.scrollY each time it fires. That delta is the velocity — large and positive when scrolling down fast, negative when scrolling up. It feeds both the speed boost and the skew, so the marquee reacts to how fast and which way you scroll.

The stored velocity is multiplied by 0.9 every animation frame, so it decays exponentially toward zero once new scroll events stop arriving. That turns an abrupt stop into a natural ease-out where the words coast back to the constant BASE drift instead of halting instantly.

Each frame the track applies skewX in addition to translateX. The skew is derived from velocity and clamped to ±18 degrees, and multiplied by each row's direction, so fast scrolls visibly lean the giant letters as if dragged through the air, while a still page sits at zero skew.

Yes. Each track repeats the word list several times and its loop width is scrollWidth / 2. When translateX passes that width in either direction it wraps by adding or subtracting the width, landing on an identical frame, so the loop is seamless. Horizontal mask gradients also fade both ends.

Run the rAF loop in a mount effect and store the frame id to cancel on unmount. Keep the latest velocity in a ref (not state) so updating it doesn't re-render every frame. Attach the passive scroll listener in the same effect with cleanup. Use refs for the track elements. The CSS, including text-stroke and the mask gradients, ports directly to Tailwind arbitrary values.

Scroll Velocity Marquee — Export as HTML, React, Vue, Angular & Tailwind

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Scroll Velocity Marquee</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#f4f4f8}
    
    .sv-page{min-height:240vh}
    .sv-spacer{height:60vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:14px;letter-spacing:.04em}
    
    .sv-rows{display:flex;flex-direction:column;gap:14px;padding:30px 0}
    .sv-row{overflow:hidden;white-space:nowrap;-webkit-mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent);mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent)}
    .sv-track{display:inline-flex;will-change:transform}
    .sv-item{display:inline-flex;align-items:center;gap:26px;padding:0 26px;font-size:clamp(34px,7vw,72px);font-weight:900;letter-spacing:-.03em;text-transform:uppercase}
    .sv-item span{color:transparent;-webkit-text-stroke:1.4px #6d28d9}
    .sv-item.fill span{color:#a78bfa;-webkit-text-stroke:0}
    .sv-dot{width:14px;height:14px;border-radius:50%;background:#22d3ee;flex-shrink:0}
  </style>
</head>
<body>
  <div class="sv-page">
    <div class="sv-spacer">↓ scroll to speed up the marquee ↓</div>
    <div class="sv-rows">
      <div class="sv-row" data-dir="1"><div class="sv-track" id="svTrackA"></div></div>
      <div class="sv-row" data-dir="-1"><div class="sv-track" id="svTrackB"></div></div>
    </div>
    <div class="sv-spacer">keep scrolling — the words skew with velocity</div>
  </div>
  <script>
    var WORDS = ['Design', 'Build', 'Ship', 'Repeat'];
    
    function fillTrack(track) {
      var html = '';
      for (var r = 0; r < 4; r++) {
        WORDS.forEach(function (w, i) {
          html += '<span class="sv-item' + (i % 2 ? ' fill' : '') + '"><span>' + w + '</span><i class="sv-dot"></i></span>';
        });
      }
      track.innerHTML = html;
    }
    
    var rows = Array.prototype.slice.call(document.querySelectorAll('.sv-row')).map(function (row) {
      var track = row.querySelector('.sv-track');
      fillTrack(track);
      return {
        track: track,
        dir: parseFloat(row.getAttribute('data-dir')),
        pos: 0,
        width: 0
      };
    });
    
    requestAnimationFrame(function () {
      rows.forEach(function (r) { r.width = r.track.scrollWidth / 2; });
    });
    
    // Track scroll velocity: difference in scrollY between frames.
    var lastScroll = window.scrollY, velocity = 0;
    window.addEventListener('scroll', function () {
      velocity = window.scrollY - lastScroll;
      lastScroll = window.scrollY;
    }, { passive: true });
    
    var BASE = 1.4;     // px per frame at rest
    var last = performance.now();
    
    function loop(now) {
      var dt = Math.min((now - last) / 16.67, 3); last = now;
      // Velocity decays each frame so the boost eases out after you stop scrolling.
      velocity *= 0.9;
      var boost = Math.abs(velocity) * 0.25;
      var skew = Math.max(-18, Math.min(18, velocity * 0.4));
    
      rows.forEach(function (r) {
        if (!r.width) r.width = r.track.scrollWidth / 2;
        var speed = (BASE + boost) * dt * r.dir + velocity * 0.3 * r.dir;
        r.pos -= speed;
        if (r.pos <= -r.width) r.pos += r.width;
        if (r.pos >= 0) r.pos -= r.width;
        r.track.style.transform = 'translateX(' + r.pos + 'px) skewX(' + (skew * r.dir) + 'deg)';
      });
      requestAnimationFrame(loop);
    }
    requestAnimationFrame(loop);
  </script>
</body>
</html>
Tailwind
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Scroll Velocity Marquee</title>
  <!-- Tailwind CSS v3+ — arbitrary value classes (e.g. bg-[#0f172a]) are valid -->
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    * {
      box-sizing:border-box;margin:0;padding:0
    }

    body {
      font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#f4f4f8
    }

    .sv-item {
      display:inline-flex;align-items:center;gap:26px;padding:0 26px;font-size:clamp(34px,7vw,72px);font-weight:900;letter-spacing:-.03em;text-transform:uppercase
    }

    .sv-item span {
      color:transparent;-webkit-text-stroke:1.4px #6d28d9
    }

    .sv-item.fill span {
      color:#a78bfa;-webkit-text-stroke:0
    }

    .sv-dot {
      width:14px;height:14px;border-radius:50%;background:#22d3ee;flex-shrink:0
    }
  </style>
</head>
<body>
  <div class="min-h-[240vh]">
    <div class="h-[60vh] flex items-center justify-center text-[#5b5b72] text-sm tracking-[.04em]">↓ scroll to speed up the marquee ↓</div>
    <div class="flex flex-col gap-3.5 py-[30px] px-0">
      <div class="sv-row overflow-hidden whitespace-nowrap [-webkit-mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)] [mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)]" data-dir="1"><div class="sv-track inline-flex [will-change:transform]" id="svTrackA"></div></div>
      <div class="sv-row overflow-hidden whitespace-nowrap [-webkit-mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)] [mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)]" data-dir="-1"><div class="sv-track inline-flex [will-change:transform]" id="svTrackB"></div></div>
    </div>
    <div class="h-[60vh] flex items-center justify-center text-[#5b5b72] text-sm tracking-[.04em]">keep scrolling — the words skew with velocity</div>
  </div>
  <script>
    var WORDS = ['Design', 'Build', 'Ship', 'Repeat'];
    
    function fillTrack(track) {
      var html = '';
      for (var r = 0; r < 4; r++) {
        WORDS.forEach(function (w, i) {
          html += '<span class="sv-item' + (i % 2 ? ' fill' : '') + '"><span>' + w + '</span><i class="sv-dot"></i></span>';
        });
      }
      track.innerHTML = html;
    }
    
    var rows = Array.prototype.slice.call(document.querySelectorAll('.sv-row')).map(function (row) {
      var track = row.querySelector('.sv-track');
      fillTrack(track);
      return {
        track: track,
        dir: parseFloat(row.getAttribute('data-dir')),
        pos: 0,
        width: 0
      };
    });
    
    requestAnimationFrame(function () {
      rows.forEach(function (r) { r.width = r.track.scrollWidth / 2; });
    });
    
    // Track scroll velocity: difference in scrollY between frames.
    var lastScroll = window.scrollY, velocity = 0;
    window.addEventListener('scroll', function () {
      velocity = window.scrollY - lastScroll;
      lastScroll = window.scrollY;
    }, { passive: true });
    
    var BASE = 1.4;     // px per frame at rest
    var last = performance.now();
    
    function loop(now) {
      var dt = Math.min((now - last) / 16.67, 3); last = now;
      // Velocity decays each frame so the boost eases out after you stop scrolling.
      velocity *= 0.9;
      var boost = Math.abs(velocity) * 0.25;
      var skew = Math.max(-18, Math.min(18, velocity * 0.4));
    
      rows.forEach(function (r) {
        if (!r.width) r.width = r.track.scrollWidth / 2;
        var speed = (BASE + boost) * dt * r.dir + velocity * 0.3 * r.dir;
        r.pos -= speed;
        if (r.pos <= -r.width) r.pos += r.width;
        if (r.pos >= 0) r.pos -= r.width;
        r.track.style.transform = 'translateX(' + r.pos + 'px) skewX(' + (skew * r.dir) + 'deg)';
      });
      requestAnimationFrame(loop);
    }
    requestAnimationFrame(loop);
  </script>
</body>
</html>
React
import React, { useEffect } from 'react';

// CSS — optionally move to ScrollVelocityMarquee.module.css
const css = `
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#f4f4f8}

.sv-page{min-height:240vh}
.sv-spacer{height:60vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:14px;letter-spacing:.04em}

.sv-rows{display:flex;flex-direction:column;gap:14px;padding:30px 0}
.sv-row{overflow:hidden;white-space:nowrap;-webkit-mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent);mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent)}
.sv-track{display:inline-flex;will-change:transform}
.sv-item{display:inline-flex;align-items:center;gap:26px;padding:0 26px;font-size:clamp(34px,7vw,72px);font-weight:900;letter-spacing:-.03em;text-transform:uppercase}
.sv-item span{color:transparent;-webkit-text-stroke:1.4px #6d28d9}
.sv-item.fill span{color:#a78bfa;-webkit-text-stroke:0}
.sv-dot{width:14px;height:14px;border-radius:50%;background:#22d3ee;flex-shrink:0}
`;

export default function ScrollVelocityMarquee() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var WORDS = ['Design', 'Build', 'Ship', 'Repeat'];
    
    function fillTrack(track) {
      var html = '';
      for (var r = 0; r < 4; r++) {
        WORDS.forEach(function (w, i) {
          html += '<span class="sv-item' + (i % 2 ? ' fill' : '') + '"><span>' + w + '</span><i class="sv-dot"></i></span>';
        });
      }
      track.innerHTML = html;
    }
    
    var rows = Array.prototype.slice.call(document.querySelectorAll('.sv-row')).map(function (row) {
      var track = row.querySelector('.sv-track');
      fillTrack(track);
      return {
        track: track,
        dir: parseFloat(row.getAttribute('data-dir')),
        pos: 0,
        width: 0
      };
    });
    
    requestAnimationFrame(function () {
      rows.forEach(function (r) { r.width = r.track.scrollWidth / 2; });
    });
    
    // Track scroll velocity: difference in scrollY between frames.
    var lastScroll = window.scrollY, velocity = 0;
    window.addEventListener('scroll', function () {
      velocity = window.scrollY - lastScroll;
      lastScroll = window.scrollY;
    }, { passive: true });
    
    var BASE = 1.4;     // px per frame at rest
    var last = performance.now();
    
    function loop(now) {
      var dt = Math.min((now - last) / 16.67, 3); last = now;
      // Velocity decays each frame so the boost eases out after you stop scrolling.
      velocity *= 0.9;
      var boost = Math.abs(velocity) * 0.25;
      var skew = Math.max(-18, Math.min(18, velocity * 0.4));
    
      rows.forEach(function (r) {
        if (!r.width) r.width = r.track.scrollWidth / 2;
        var speed = (BASE + boost) * dt * r.dir + velocity * 0.3 * r.dir;
        r.pos -= speed;
        if (r.pos <= -r.width) r.pos += r.width;
        if (r.pos >= 0) r.pos -= r.width;
        r.track.style.transform = 'translateX(' + r.pos + 'px) skewX(' + (skew * r.dir) + 'deg)';
      });
      requestAnimationFrame(loop);
    }
    requestAnimationFrame(loop);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{css}</style>
      <div className="sv-page">
        <div className="sv-spacer">↓ scroll to speed up the marquee ↓</div>
        <div className="sv-rows">
          <div className="sv-row" data-dir="1"><div className="sv-track" id="svTrackA"></div></div>
          <div className="sv-row" data-dir="-1"><div className="sv-track" id="svTrackB"></div></div>
        </div>
        <div className="sv-spacer">keep scrolling — the words skew with velocity</div>
      </div>
    </>
  );
}
React + Tailwind
import React, { useEffect } from 'react';
// Requires Tailwind CSS v3+ — https://tailwindcss.com/docs/installation
// Arbitrary value classes (e.g. bg-[#0f172a]) are valid Tailwind v3+

export default function ScrollVelocityMarquee() {
  // Auto-generated escape hatch: the original snippet's vanilla JS runs once
  // after mount and queries the rendered DOM. For idiomatic React, lift this
  // into state + handlers.
  useEffect(() => {
    const _listeners = [];
    const _originalAddEventListener = EventTarget.prototype.addEventListener;
    EventTarget.prototype.addEventListener = function(type, listener, options) {
      _listeners.push({ target: this, type, listener, options });
      return _originalAddEventListener.call(this, type, listener, options);
    };
    var WORDS = ['Design', 'Build', 'Ship', 'Repeat'];
    
    function fillTrack(track) {
      var html = '';
      for (var r = 0; r < 4; r++) {
        WORDS.forEach(function (w, i) {
          html += '<span class="sv-item' + (i % 2 ? ' fill' : '') + '"><span>' + w + '</span><i class="sv-dot"></i></span>';
        });
      }
      track.innerHTML = html;
    }
    
    var rows = Array.prototype.slice.call(document.querySelectorAll('.sv-row')).map(function (row) {
      var track = row.querySelector('.sv-track');
      fillTrack(track);
      return {
        track: track,
        dir: parseFloat(row.getAttribute('data-dir')),
        pos: 0,
        width: 0
      };
    });
    
    requestAnimationFrame(function () {
      rows.forEach(function (r) { r.width = r.track.scrollWidth / 2; });
    });
    
    // Track scroll velocity: difference in scrollY between frames.
    var lastScroll = window.scrollY, velocity = 0;
    window.addEventListener('scroll', function () {
      velocity = window.scrollY - lastScroll;
      lastScroll = window.scrollY;
    }, { passive: true });
    
    var BASE = 1.4;     // px per frame at rest
    var last = performance.now();
    
    function loop(now) {
      var dt = Math.min((now - last) / 16.67, 3); last = now;
      // Velocity decays each frame so the boost eases out after you stop scrolling.
      velocity *= 0.9;
      var boost = Math.abs(velocity) * 0.25;
      var skew = Math.max(-18, Math.min(18, velocity * 0.4));
    
      rows.forEach(function (r) {
        if (!r.width) r.width = r.track.scrollWidth / 2;
        var speed = (BASE + boost) * dt * r.dir + velocity * 0.3 * r.dir;
        r.pos -= speed;
        if (r.pos <= -r.width) r.pos += r.width;
        if (r.pos >= 0) r.pos -= r.width;
        r.track.style.transform = 'translateX(' + r.pos + 'px) skewX(' + (skew * r.dir) + 'deg)';
      });
      requestAnimationFrame(loop);
    }
    requestAnimationFrame(loop);
    // Cleanup: restore addEventListener and remove all listeners
    return () => {
      EventTarget.prototype.addEventListener = _originalAddEventListener;
      for (const { target, type, listener, options } of _listeners) {
        target.removeEventListener(type, listener, options);
      }
    };
  }, []);

  return (
    <>
      <style>{`
* {
  box-sizing:border-box;margin:0;padding:0
}

body {
  font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#f4f4f8
}

.sv-item {
  display:inline-flex;align-items:center;gap:26px;padding:0 26px;font-size:clamp(34px,7vw,72px);font-weight:900;letter-spacing:-.03em;text-transform:uppercase
}

.sv-item span {
  color:transparent;-webkit-text-stroke:1.4px #6d28d9
}

.sv-item.fill span {
  color:#a78bfa;-webkit-text-stroke:0
}

.sv-dot {
  width:14px;height:14px;border-radius:50%;background:#22d3ee;flex-shrink:0
}
      `}</style>
      <div className="min-h-[240vh]">
        <div className="h-[60vh] flex items-center justify-center text-[#5b5b72] text-sm tracking-[.04em]">↓ scroll to speed up the marquee ↓</div>
        <div className="flex flex-col gap-3.5 py-[30px] px-0">
          <div className="sv-row overflow-hidden whitespace-nowrap [-webkit-mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)] [mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)]" data-dir="1"><div className="sv-track inline-flex [will-change:transform]" id="svTrackA"></div></div>
          <div className="sv-row overflow-hidden whitespace-nowrap [-webkit-mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)] [mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)]" data-dir="-1"><div className="sv-track inline-flex [will-change:transform]" id="svTrackB"></div></div>
        </div>
        <div className="h-[60vh] flex items-center justify-center text-[#5b5b72] text-sm tracking-[.04em]">keep scrolling — the words skew with velocity</div>
      </div>
    </>
  );
}
Vue
<template>
  <div class="sv-page">
    <div class="sv-spacer">↓ scroll to speed up the marquee ↓</div>
    <div class="sv-rows">
      <div class="sv-row" data-dir="1"><div class="sv-track" id="svTrackA"></div></div>
      <div class="sv-row" data-dir="-1"><div class="sv-track" id="svTrackB"></div></div>
    </div>
    <div class="sv-spacer">keep scrolling — the words skew with velocity</div>
  </div>
</template>

<script setup>
import { onMounted } from 'vue';

var WORDS = ['Design', 'Build', 'Ship', 'Repeat'];
function fillTrack(track) {
  var html = '';
  for (var r = 0; r < 4; r++) {
    WORDS.forEach(function (w, i) {
      html += '<span class="sv-item' + (i % 2 ? ' fill' : '') + '"><span>' + w + '</span><i class="sv-dot"></i></span>';
    });
  }
  track.innerHTML = html;
}
let rows;
// Track scroll velocity: difference in scrollY between frames.
var lastScroll = window.scrollY, velocity = 0;
var BASE = 1.4;
// px per frame at rest
var last = performance.now();
function loop(now) {
  var dt = Math.min((now - last) / 16.67, 3); last = now;
  // Velocity decays each frame so the boost eases out after you stop scrolling.
  velocity *= 0.9;
  var boost = Math.abs(velocity) * 0.25;
  var skew = Math.max(-18, Math.min(18, velocity * 0.4));

  rows.forEach(function (r) {
    if (!r.width) r.width = r.track.scrollWidth / 2;
    var speed = (BASE + boost) * dt * r.dir + velocity * 0.3 * r.dir;
    r.pos -= speed;
    if (r.pos <= -r.width) r.pos += r.width;
    if (r.pos >= 0) r.pos -= r.width;
    r.track.style.transform = 'translateX(' + r.pos + 'px) skewX(' + (skew * r.dir) + 'deg)';
  });
  requestAnimationFrame(loop);
}

onMounted(() => {
  rows = Array.prototype.slice.call(document.querySelectorAll('.sv-row')).map(function (row) {
    var track = row.querySelector('.sv-track');
    fillTrack(track);
    return {
      track: track,
      dir: parseFloat(row.getAttribute('data-dir')),
      pos: 0,
      width: 0
    };
  });
  requestAnimationFrame(function () {
    rows.forEach(function (r) { r.width = r.track.scrollWidth / 2; });
  });
  window.addEventListener('scroll', function () {
    velocity = window.scrollY - lastScroll;
    lastScroll = window.scrollY;
  }, { passive: true });
  requestAnimationFrame(loop);
});
</script>

<style scoped>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#f4f4f8}

.sv-page{min-height:240vh}
.sv-spacer{height:60vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:14px;letter-spacing:.04em}

.sv-rows{display:flex;flex-direction:column;gap:14px;padding:30px 0}
.sv-row{overflow:hidden;white-space:nowrap;-webkit-mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent);mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent)}
.sv-track{display:inline-flex;will-change:transform}
.sv-item{display:inline-flex;align-items:center;gap:26px;padding:0 26px;font-size:clamp(34px,7vw,72px);font-weight:900;letter-spacing:-.03em;text-transform:uppercase}
.sv-item span{color:transparent;-webkit-text-stroke:1.4px #6d28d9}
.sv-item.fill span{color:#a78bfa;-webkit-text-stroke:0}
.sv-dot{width:14px;height:14px;border-radius:50%;background:#22d3ee;flex-shrink:0}
</style>
Angular
// @ts-nocheck
// Note: vanilla JS DOM manipulation is preserved as-is inside ngAfterViewInit().
// For idiomatic Angular, replace document.getElementById() with @ViewChild() refs
// and move state into component properties with two-way binding.
import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-scroll-velocity-marquee',
  standalone: true,
  imports: [CommonModule],
  encapsulation: ViewEncapsulation.None,
  template: `
    <div class="sv-page">
      <div class="sv-spacer">↓ scroll to speed up the marquee ↓</div>
      <div class="sv-rows">
        <div class="sv-row" data-dir="1"><div class="sv-track" id="svTrackA"></div></div>
        <div class="sv-row" data-dir="-1"><div class="sv-track" id="svTrackB"></div></div>
      </div>
      <div class="sv-spacer">keep scrolling — the words skew with velocity</div>
    </div>
  `,
  styles: [`
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0f;color:#f4f4f8}
    
    .sv-page{min-height:240vh}
    .sv-spacer{height:60vh;display:flex;align-items:center;justify-content:center;color:#5b5b72;font-size:14px;letter-spacing:.04em}
    
    .sv-rows{display:flex;flex-direction:column;gap:14px;padding:30px 0}
    .sv-row{overflow:hidden;white-space:nowrap;-webkit-mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent);mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent)}
    .sv-track{display:inline-flex;will-change:transform}
    .sv-item{display:inline-flex;align-items:center;gap:26px;padding:0 26px;font-size:clamp(34px,7vw,72px);font-weight:900;letter-spacing:-.03em;text-transform:uppercase}
    .sv-item span{color:transparent;-webkit-text-stroke:1.4px #6d28d9}
    .sv-item.fill span{color:#a78bfa;-webkit-text-stroke:0}
    .sv-dot{width:14px;height:14px;border-radius:50%;background:#22d3ee;flex-shrink:0}
  `]
})
export class ScrollVelocityMarqueeComponent implements AfterViewInit {
  ngAfterViewInit(): void {
    var WORDS = ['Design', 'Build', 'Ship', 'Repeat'];
    
    function fillTrack(track) {
      var html = '';
      for (var r = 0; r < 4; r++) {
        WORDS.forEach(function (w, i) {
          html += '<span class="sv-item' + (i % 2 ? ' fill' : '') + '"><span>' + w + '</span><i class="sv-dot"></i></span>';
        });
      }
      track.innerHTML = html;
    }
    
    var rows = Array.prototype.slice.call(document.querySelectorAll('.sv-row')).map(function (row) {
      var track = row.querySelector('.sv-track');
      fillTrack(track);
      return {
        track: track,
        dir: parseFloat(row.getAttribute('data-dir')),
        pos: 0,
        width: 0
      };
    });
    
    requestAnimationFrame(function () {
      rows.forEach(function (r) { r.width = r.track.scrollWidth / 2; });
    });
    
    // Track scroll velocity: difference in scrollY between frames.
    var lastScroll = window.scrollY, velocity = 0;
    window.addEventListener('scroll', function () {
      velocity = window.scrollY - lastScroll;
      lastScroll = window.scrollY;
    }, { passive: true });
    
    var BASE = 1.4;     // px per frame at rest
    var last = performance.now();
    
    function loop(now) {
      var dt = Math.min((now - last) / 16.67, 3); last = now;
      // Velocity decays each frame so the boost eases out after you stop scrolling.
      velocity *= 0.9;
      var boost = Math.abs(velocity) * 0.25;
      var skew = Math.max(-18, Math.min(18, velocity * 0.4));
    
      rows.forEach(function (r) {
        if (!r.width) r.width = r.track.scrollWidth / 2;
        var speed = (BASE + boost) * dt * r.dir + velocity * 0.3 * r.dir;
        r.pos -= speed;
        if (r.pos <= -r.width) r.pos += r.width;
        if (r.pos >= 0) r.pos -= r.width;
        r.track.style.transform = 'translateX(' + r.pos + 'px) skewX(' + (skew * r.dir) + 'deg)';
      });
      requestAnimationFrame(loop);
    }
    requestAnimationFrame(loop);

    // Bind inline-handler functions to the component so the template can call them
    Object.assign(this, { fillTrack, loop });
  }
}