Patterns Medium
Scroll Velocity Text
Horizontal scrolling text marquee that speeds up or slows down based on the user's scroll velocity.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--marquee-gap: 2rem;
--text-color: rgba(255, 255, 255, 0.08);
--text-color-accent: rgba(129, 140, 248, 0.25);
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e2e8f0;
min-height: 400vh;
overflow-x: hidden;
}
.hero {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
text-align: center;
padding: 2rem;
position: relative;
z-index: 2;
}
.hero h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
color: rgba(148, 163, 184, 0.8);
font-size: 1.125rem;
}
.scroll-hint {
margin-top: 2rem;
color: rgba(148, 163, 184, 0.5);
font-size: 0.875rem;
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(8px);
}
}
/* Scroll Velocity Marquee */
.velocity-marquee {
position: relative;
overflow: hidden;
white-space: nowrap;
padding: 1.5rem 0;
user-select: none;
}
.velocity-marquee-track {
display: inline-flex;
gap: var(--marquee-gap);
will-change: transform;
}
.velocity-marquee-text {
font-size: clamp(3rem, 10vw, 8rem);
font-weight: 900;
letter-spacing: -0.03em;
color: var(--text-color);
text-transform: uppercase;
flex-shrink: 0;
padding-right: var(--marquee-gap);
}
.velocity-marquee-text.accent {
color: var(--text-color-accent);
}
/* Separator bands */
.marquee-section {
position: relative;
padding: 2rem 0;
}
.marquee-section::before,
.marquee-section::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 1px;
background: rgba(255, 255, 255, 0.06);
}
.marquee-section::before {
top: 0;
}
.marquee-section::after {
bottom: 0;
}
/* Content sections between marquees */
.content-block {
max-width: 700px;
margin: 0 auto;
padding: 6rem 2rem;
text-align: center;
}
.content-block h2 {
font-size: 1.75rem;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 1rem;
}
.content-block p {
color: rgba(148, 163, 184, 0.8);
line-height: 1.8;
font-size: 1rem;
}
/* Speed indicator */
.speed-indicator {
position: fixed;
bottom: 2rem;
right: 2rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0.75rem 1.25rem;
font-size: 0.8rem;
color: rgba(148, 163, 184, 0.7);
z-index: 100;
backdrop-filter: blur(12px);
font-variant-numeric: tabular-nums;
}
.speed-indicator .value {
color: #818cf8;
font-weight: 700;
font-size: 1rem;
}// Scroll Velocity Text — marquee speed responds to scroll velocity
(function () {
"use strict";
const BASE_SPEED = 0.5; // px per frame at rest
const SPEED_MULTIPLIER = 3; // how much scroll velocity amplifies speed
const SMOOTHING = 0.05; // lerp factor (lower = smoother)
const marquees = [];
let lastScrollY = window.scrollY;
let scrollVelocity = 0;
let smoothVelocity = 0;
let animationId;
function lerp(a, b, t) {
return a + (b - a) * t;
}
function initMarquees() {
const elements = document.querySelectorAll(".velocity-marquee");
elements.forEach((el) => {
const track = el.querySelector(".velocity-marquee-track");
if (!track) return;
// Duplicate children for seamless loop
const children = Array.from(track.children);
children.forEach((child) => {
const clone = child.cloneNode(true);
track.appendChild(clone);
});
const direction = el.dataset.direction === "right" ? 1 : -1;
const speed = parseFloat(el.dataset.speed) || BASE_SPEED;
marquees.push({
track,
direction,
baseSpeed: speed,
position: 0,
halfWidth: 0,
});
});
// Measure half-width after cloning
requestAnimationFrame(() => {
marquees.forEach((m) => {
m.halfWidth = m.track.scrollWidth / 2;
});
});
}
function onScroll() {
const currentY = window.scrollY;
scrollVelocity = currentY - lastScrollY;
lastScrollY = currentY;
}
function animate() {
// Smooth the velocity
smoothVelocity = lerp(smoothVelocity, scrollVelocity, SMOOTHING);
// Decay the raw velocity so it returns to 0 when not scrolling
scrollVelocity = lerp(scrollVelocity, 0, SMOOTHING);
const absVelocity = Math.abs(smoothVelocity);
marquees.forEach((m) => {
const speed = m.baseSpeed + absVelocity * SPEED_MULTIPLIER * 0.1;
// Scroll direction can influence marquee direction
const scrollDir = smoothVelocity >= 0 ? 1 : -1;
m.position += speed * m.direction * scrollDir;
// Reset for seamless loop
if (Math.abs(m.position) >= m.halfWidth) {
m.position = 0;
}
m.track.style.transform = `translateX(${m.position}px)`;
});
// Update speed indicator if present
const indicator = document.getElementById("speed-value");
if (indicator) {
indicator.textContent = absVelocity.toFixed(1);
}
animationId = requestAnimationFrame(animate);
}
function init() {
initMarquees();
window.addEventListener("scroll", onScroll, { passive: true });
animationId = requestAnimationFrame(animate);
}
function destroy() {
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(animationId);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Cleanup on page hide
window.addEventListener("pagehide", destroy);
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scroll Velocity Text</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<section class="hero">
<h1>Scroll Velocity Text</h1>
<p>Marquee speed reacts to how fast you scroll</p>
<span class="scroll-hint">Scroll to see the speed change</span>
</section>
<div class="marquee-section">
<div class="velocity-marquee" data-direction="left" data-speed="0.5">
<div class="velocity-marquee-track">
<span class="velocity-marquee-text">STEAL THIS</span>
<span class="velocity-marquee-text accent">DESIGN</span>
<span class="velocity-marquee-text">STEAL THIS</span>
<span class="velocity-marquee-text accent">DESIGN</span>
</div>
</div>
</div>
<div class="content-block">
<h2>Velocity-Driven Animation</h2>
<p>
The marquee text above and below responds to your scroll speed in real time.
Scroll faster and the text accelerates. Stop scrolling and it gently returns
to its base speed. The direction can even reverse when you scroll up.
</p>
</div>
<div class="marquee-section">
<div class="velocity-marquee" data-direction="right" data-speed="0.3">
<div class="velocity-marquee-track">
<span class="velocity-marquee-text">COMPONENTS</span>
<span class="velocity-marquee-text accent">PATTERNS</span>
<span class="velocity-marquee-text">LAYOUTS</span>
<span class="velocity-marquee-text accent">EFFECTS</span>
</div>
</div>
</div>
<div class="content-block">
<h2>Smooth Interpolation</h2>
<p>
Linear interpolation (lerp) ensures the speed transitions are buttery smooth,
never jerky. The velocity decays naturally when scrolling stops, creating
an organic, physics-inspired feel.
</p>
</div>
<div class="marquee-section">
<div class="velocity-marquee" data-direction="left" data-speed="0.8">
<div class="velocity-marquee-track">
<span class="velocity-marquee-text accent">BUILD</span>
<span class="velocity-marquee-text">SHIP</span>
<span class="velocity-marquee-text accent">ITERATE</span>
<span class="velocity-marquee-text">REPEAT</span>
</div>
</div>
</div>
<div class="content-block">
<h2>Keep Scrolling</h2>
<p>
Try scrolling at different speeds — fast flicks, slow crawls, direction
changes. Each marquee band responds independently with its own base speed
and direction configuration.
</p>
</div>
<div class="marquee-section">
<div class="velocity-marquee" data-direction="right" data-speed="0.4">
<div class="velocity-marquee-track">
<span class="velocity-marquee-text">CREATIVE</span>
<span class="velocity-marquee-text accent">DEVELOPER</span>
<span class="velocity-marquee-text">TOOLS</span>
<span class="velocity-marquee-text accent">2026</span>
</div>
</div>
</div>
<div class="speed-indicator">
Speed: <span class="value" id="speed-value">0.0</span>
</div>
<script src="script.js"></script>
</body>
</html>import {
useEffect,
useRef,
useState,
useCallback,
type CSSProperties,
type ReactNode,
} from "react";
interface ScrollVelocityTextProps {
text: string | string[];
baseSpeed?: number;
speedMultiplier?: number;
direction?: "left" | "right";
className?: string;
textClassName?: string;
accentIndices?: number[];
style?: CSSProperties;
}
export function ScrollVelocityText({
text,
baseSpeed = 0.5,
speedMultiplier = 3,
direction = "left",
className = "",
textClassName = "",
accentIndices = [],
style = {},
}: ScrollVelocityTextProps) {
const trackRef = useRef<HTMLDivElement>(null);
const posRef = useRef(0);
const halfWidthRef = useRef(0);
const scrollVelRef = useRef(0);
const smoothVelRef = useRef(0);
const lastScrollRef = useRef(0);
const rafRef = useRef<number>(0);
const texts = Array.isArray(text) ? text : [text];
const dir = direction === "right" ? 1 : -1;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
useEffect(() => {
lastScrollRef.current = window.scrollY;
const onScroll = () => {
const y = window.scrollY;
scrollVelRef.current = y - lastScrollRef.current;
lastScrollRef.current = y;
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
// Measure after render
requestAnimationFrame(() => {
halfWidthRef.current = track.scrollWidth / 2;
});
const animate = () => {
smoothVelRef.current = lerp(smoothVelRef.current, scrollVelRef.current, 0.05);
scrollVelRef.current = lerp(scrollVelRef.current, 0, 0.05);
const absVel = Math.abs(smoothVelRef.current);
const speed = baseSpeed + absVel * speedMultiplier * 0.1;
const scrollDir = smoothVelRef.current >= 0 ? 1 : -1;
posRef.current += speed * dir * scrollDir;
if (Math.abs(posRef.current) >= halfWidthRef.current && halfWidthRef.current > 0) {
posRef.current = 0;
}
if (track) {
track.style.transform = `translateX(${posRef.current}px)`;
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
}, [baseSpeed, speedMultiplier, dir]);
const wrapperStyle: CSSProperties = {
overflow: "hidden",
whiteSpace: "nowrap" as const,
padding: "1.5rem 0",
userSelect: "none",
...style,
};
const trackStyle: CSSProperties = {
display: "inline-flex",
gap: "2rem",
willChange: "transform",
};
const textStyle = (accent: boolean): CSSProperties => ({
fontSize: "clamp(3rem, 10vw, 8rem)",
fontWeight: 900,
letterSpacing: "-0.03em",
color: accent ? "rgba(129, 140, 248, 0.25)" : "rgba(255, 255, 255, 0.08)",
textTransform: "uppercase" as const,
flexShrink: 0,
paddingRight: "2rem",
});
// Duplicate for seamless loop
const allTexts = [...texts, ...texts];
return (
<div className={className} style={wrapperStyle}>
<div ref={trackRef} style={trackStyle}>
{allTexts.map((t, i) => (
<span
key={`${t}-${i}`}
className={textClassName}
style={textStyle(accentIndices.includes(i % texts.length))}
>
{t}
</span>
))}
</div>
</div>
);
}
// Demo usage
export default function ScrollVelocityTextDemo() {
const [speed, setSpeed] = useState(0);
useEffect(() => {
let lastY = window.scrollY;
let smoothVel = 0;
let raf: number;
const onScroll = () => {
const y = window.scrollY;
smoothVel = Math.abs(y - lastY);
lastY = y;
};
const tick = () => {
smoothVel *= 0.95;
setSpeed(smoothVel);
raf = requestAnimationFrame(tick);
};
window.addEventListener("scroll", onScroll, { passive: true });
raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf);
};
}, []);
const sectionStyle: CSSProperties = {
position: "relative",
padding: "2rem 0",
borderTop: "1px solid rgba(255,255,255,0.06)",
borderBottom: "1px solid rgba(255,255,255,0.06)",
};
const blockStyle: CSSProperties = {
maxWidth: 700,
margin: "0 auto",
padding: "6rem 2rem",
textAlign: "center",
};
return (
<div
style={{
background: "#0a0a0a",
minHeight: "400vh",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#e2e8f0",
}}
>
<section
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
textAlign: "center",
padding: "2rem",
position: "relative",
zIndex: 2,
}}
>
<h1
style={{
fontSize: "clamp(2rem, 5vw, 3.5rem)",
fontWeight: 800,
letterSpacing: "-0.03em",
background: "linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
Scroll Velocity Text
</h1>
<p style={{ color: "rgba(148, 163, 184, 0.8)", fontSize: "1.125rem" }}>
Marquee speed reacts to how fast you scroll
</p>
<span
style={{ marginTop: "2rem", color: "rgba(148, 163, 184, 0.5)", fontSize: "0.875rem" }}
>
Scroll to see the speed change
</span>
</section>
<div style={sectionStyle}>
<ScrollVelocityText
text={["STEAL THIS", "DESIGN", "STEAL THIS", "DESIGN"]}
direction="left"
baseSpeed={0.5}
accentIndices={[1, 3]}
/>
</div>
<div style={blockStyle}>
<h2
style={{ fontSize: "1.75rem", fontWeight: 700, color: "#f1f5f9", marginBottom: "1rem" }}
>
Velocity-Driven Animation
</h2>
<p style={{ color: "rgba(148,163,184,0.8)", lineHeight: 1.8 }}>
The marquee text responds to your scroll speed in real time. Scroll faster and the text
accelerates.
</p>
</div>
<div style={sectionStyle}>
<ScrollVelocityText
text={["COMPONENTS", "PATTERNS", "LAYOUTS", "EFFECTS"]}
direction="right"
baseSpeed={0.3}
accentIndices={[1, 3]}
/>
</div>
<div style={blockStyle}>
<h2
style={{ fontSize: "1.75rem", fontWeight: 700, color: "#f1f5f9", marginBottom: "1rem" }}
>
Smooth Interpolation
</h2>
<p style={{ color: "rgba(148,163,184,0.8)", lineHeight: 1.8 }}>
Linear interpolation ensures smooth transitions. Velocity decays naturally when scrolling
stops.
</p>
</div>
<div style={sectionStyle}>
<ScrollVelocityText
text={["BUILD", "SHIP", "ITERATE", "REPEAT"]}
direction="left"
baseSpeed={0.8}
accentIndices={[0, 2]}
/>
</div>
<div
style={{
position: "fixed",
bottom: "2rem",
right: "2rem",
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 12,
padding: "0.75rem 1.25rem",
fontSize: "0.8rem",
color: "rgba(148,163,184,0.7)",
zIndex: 100,
backdropFilter: "blur(12px)",
fontVariantNumeric: "tabular-nums",
}}
>
Speed:{" "}
<span style={{ color: "#818cf8", fontWeight: 700, fontSize: "1rem" }}>
{speed.toFixed(1)}
</span>
</div>
</div>
);
}<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
const props = defineProps({
text: { type: [String, Array], default: () => [] },
baseSpeed: { type: Number, default: 0.5 },
speedMultiplier: { type: Number, default: 3 },
direction: { type: String, default: "left" },
accentIndices: { type: Array, default: () => [] },
});
const trackEl = ref(null);
let pos = 0;
let halfWidth = 0;
let scrollVel = 0;
let smoothVel = 0;
let lastScroll = 0;
let raf = 0;
let onScroll = null;
const texts = computed(() => (Array.isArray(props.text) ? props.text : [props.text]));
const allTexts = computed(() => [...texts.value, ...texts.value]);
const dir = computed(() => (props.direction === "right" ? 1 : -1));
function lerp(a, b, t) {
return a + (b - a) * t;
}
function isAccent(i) {
return props.accentIndices.includes(i % texts.value.length);
}
function textStyle(i) {
return {
fontSize: "clamp(3rem, 10vw, 8rem)",
fontWeight: 900,
letterSpacing: "-0.03em",
color: isAccent(i) ? "rgba(129, 140, 248, 0.25)" : "rgba(255, 255, 255, 0.08)",
textTransform: "uppercase",
flexShrink: 0,
paddingRight: "2rem",
};
}
onMounted(() => {
lastScroll = window.scrollY;
onScroll = () => {
const y = window.scrollY;
scrollVel = y - lastScroll;
lastScroll = y;
};
window.addEventListener("scroll", onScroll, { passive: true });
requestAnimationFrame(() => {
if (trackEl.value) halfWidth = trackEl.value.scrollWidth / 2;
});
const animate = () => {
smoothVel = lerp(smoothVel, scrollVel, 0.05);
scrollVel = lerp(scrollVel, 0, 0.05);
const absVel = Math.abs(smoothVel);
const speed = props.baseSpeed + absVel * props.speedMultiplier * 0.1;
const scrollDir = smoothVel >= 0 ? 1 : -1;
pos += speed * dir.value * scrollDir;
if (Math.abs(pos) >= halfWidth && halfWidth > 0) {
pos = 0;
}
if (trackEl.value) {
trackEl.value.style.transform = `translateX(${pos}px)`;
}
raf = requestAnimationFrame(animate);
};
raf = requestAnimationFrame(animate);
});
onUnmounted(() => {
if (onScroll) window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf);
});
</script>
<template>
<div style="overflow: hidden; white-space: nowrap; padding: 1.5rem 0; user-select: none;">
<div ref="trackEl" style="display: inline-flex; gap: 2rem; will-change: transform;">
<span
v-for="(t, i) in allTexts"
:key="`${t}-${i}`"
:style="textStyle(i)"
>
{{ t }}
</span>
</div>
</div>
</template><script>
import { onMount, onDestroy } from "svelte";
export let text = [];
export let baseSpeed = 0.5;
export let speedMultiplier = 3;
export let direction = "left";
export let accentIndices = [];
export let style = "";
let trackEl;
let pos = 0;
let halfWidth = 0;
let scrollVel = 0;
let smoothVel = 0;
let lastScroll = 0;
let raf = 0;
$: texts = Array.isArray(text) ? text : [text];
$: allTexts = [...texts, ...texts];
$: dir = direction === "right" ? 1 : -1;
function lerp(a, b, t) {
return a + (b - a) * t;
}
function isAccent(i) {
return accentIndices.includes(i % texts.length);
}
onMount(() => {
lastScroll = window.scrollY;
const onScroll = () => {
const y = window.scrollY;
scrollVel = y - lastScroll;
lastScroll = y;
};
window.addEventListener("scroll", onScroll, { passive: true });
requestAnimationFrame(() => {
if (trackEl) halfWidth = trackEl.scrollWidth / 2;
});
const animate = () => {
smoothVel = lerp(smoothVel, scrollVel, 0.05);
scrollVel = lerp(scrollVel, 0, 0.05);
const absVel = Math.abs(smoothVel);
const speed = baseSpeed + absVel * speedMultiplier * 0.1;
const scrollDir = smoothVel >= 0 ? 1 : -1;
pos += speed * dir * scrollDir;
if (Math.abs(pos) >= halfWidth && halfWidth > 0) {
pos = 0;
}
if (trackEl) {
trackEl.style.transform = `translateX(${pos}px)`;
}
raf = requestAnimationFrame(animate);
};
raf = requestAnimationFrame(animate);
return () => {
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf);
};
});
</script>
<div style="overflow: hidden; white-space: nowrap; padding: 1.5rem 0; user-select: none; {style}">
<div bind:this={trackEl} style="display: inline-flex; gap: 2rem; will-change: transform;">
{#each allTexts as t, i}
<span
style="font-size: clamp(3rem, 10vw, 8rem); font-weight: 900; letter-spacing: -0.03em; color: {isAccent(i) ? 'rgba(129, 140, 248, 0.25)' : 'rgba(255, 255, 255, 0.08)'}; text-transform: uppercase; flex-shrink: 0; padding-right: 2rem;"
>
{t}
</span>
{/each}
</div>
</div>Scroll Velocity Text
A horizontal scrolling text marquee that dynamically adjusts its speed based on how fast the user scrolls. Faster scrolling makes the text move faster; slow or no scrolling returns it to a gentle base speed.
How it works
- A duplicated text strip is animated with CSS
translateXto create a seamless loop - JavaScript tracks scroll delta between frames to measure velocity
- The velocity is smoothed with linear interpolation and mapped to the animation speed
- The direction can reverse based on scroll direction (up vs. down)
Customization
- Set
baseSpeedto control the idle animation speed - Adjust the
speedMultiplierfor how much scroll affects the marquee - Use
--marquee-gapto control spacing between repeated text - Change direction with the
directionprop/attribute
When to use it
- Hero sections with bold typography
- Section dividers with scrolling labels
- Decorative background text bands
- Interactive portfolio page elements