More Animations Snippets
Scroll Velocity Marquee — Free HTML CSS JS Snippet
Scroll Velocity Marquee · Animations · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Scroll Velocity Marquee — Speed and Skew Driven by Scrolling

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
- 1Paste HTML, CSS, and JSTwo rows of giant words drift slowly in opposite directions.
- 2Scroll the pageThe words surge in the scroll direction and skew with the momentum.
- 3Stop scrollingThe boost decays and the rows ease back to a calm baseline drift.
- 4Scroll upVelocity goes negative and the rows reverse and lean the other way.
- 5Edit the wordsReplace the WORDS array with your own slogan.
- 6Tune the feelAdjust BASE, the boost factor, decay, and skew clamp.
Real-world uses
Common Use Cases
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
<!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><!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>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>
</>
);
}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>
</>
);
}<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>// @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 });
}
}