:root {
--bg: #070a12;
--text: #f0f4fb;
--panel: #121a2b;
--border: #263249;
--accent: #86e8ff;
--muted: #8a95a8;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
overflow-x: hidden;
}
/* Fixed canvas behind content */
#speed-canvas {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
}
section {
position: relative;
z-index: 1;
}
.hero {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: radial-gradient(ellipse at center, rgba(134, 232, 255, 0.06) 0%, transparent 70%);
}
.hero .content {
max-width: 600px;
text-align: center;
}
.eyebrow {
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 1rem;
}
.hero h1 {
font-size: 3.5rem;
font-weight: 800;
line-height: 1.1;
margin-bottom: 1.5rem;
letter-spacing: -1px;
}
.subtitle {
font-size: 1.1rem;
color: var(--muted);
}
/* Content Sections */
.section {
max-width: 760px;
margin: 0 auto;
padding: 5rem 2rem;
border-bottom: 1px solid var(--border);
}
.section h2 {
font-size: 2rem;
margin-bottom: 1.5rem;
color: var(--text);
}
.section p {
font-size: 1rem;
color: var(--muted);
margin-bottom: 1.5rem;
line-height: 1.8;
}
.section ul {
margin-left: 1.5rem;
margin-bottom: 1.5rem;
}
.section li {
color: var(--muted);
margin-bottom: 0.8rem;
line-height: 1.7;
}
code {
background: rgba(134, 232, 255, 0.1);
color: var(--accent);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 0.9rem;
}
.btn-back {
display: inline-block;
margin-top: 2rem;
padding: 0.8rem 1.5rem;
background: var(--accent);
color: var(--bg);
border-radius: 4px;
font-weight: 600;
text-decoration: none;
transition: all 0.3s ease;
}
.btn-back:hover {
background: rgba(134, 232, 255, 0.8);
transform: translateY(-2px);
}
@media (max-width: 768px) {
.hero h1 { font-size: 2.5rem; }
.section h2 { font-size: 1.5rem; }
.section { padding: 3rem 1.5rem; }
}
@media (max-width: 480px) {
.hero h1 { font-size: 2rem; }
}
html.reduced-motion #speed-canvas { display: none; }
html.reduced-motion * {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import Lenis from 'lenis';
gsap.registerPlugin(ScrollTrigger);
initDemoShell({ title: 'Velocity-Aware Scroll + Speed Lines', category: 'scroll', tech: ['lenis', 'canvas-2d', 'gsap', 'velocity'] });
const reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add('reduced-motion');
// --- Canvas Speed Lines Setup ---
const canvas = document.getElementById('speed-canvas');
const ctx = canvas.getContext('2d');
let velocity = 0;
let decayedVelocity = 0; // Smoothly decays when not scrolling
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
function drawSpeedLines(vel) {
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const absVel = Math.abs(vel);
const lineCount = Math.floor(Math.min(60, absVel * 4));
const opacity = Math.min(0.7, absVel * 0.04);
const maxLength = Math.min(canvas.width, canvas.height) * 0.5 * (absVel * 0.03);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (lineCount < 1 || opacity < 0.01) return;
for (let i = 0; i < lineCount; i++) {
const angle = (i / lineCount) * Math.PI * 2;
const minDist = 80;
const startX = cx + Math.cos(angle) * minDist;
const startY = cy + Math.sin(angle) * minDist;
const endX = cx + Math.cos(angle) * (minDist + maxLength);
const endY = cy + Math.sin(angle) * (minDist + maxLength);
const gradient = ctx.createLinearGradient(startX, startY, endX, endY);
gradient.addColorStop(0, `rgba(134, 232, 255, ${opacity})`);
gradient.addColorStop(1, `rgba(134, 232, 255, 0)`);
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.strokeStyle = gradient;
ctx.lineWidth = Math.max(0.5, absVel * 0.1);
ctx.stroke();
}
}
// Animation loop
function tick() {
// Smooth decay toward 0 when not scrolling
decayedVelocity += (velocity - decayedVelocity) * 0.12;
if (!reduced) {
drawSpeedLines(decayedVelocity);
}
// Fade velocity toward 0
velocity *= 0.9;
}
// --- Lenis Setup ---
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on('scroll', (e) => {
// Track velocity from Lenis scroll event
velocity = e.velocity;
ScrollTrigger.update();
});
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
tick();
});
gsap.ticker.lagSmoothing(0);
// --- Hero Entrance ---
if (!reduced) {
gsap.set('.hero .eyebrow', { opacity: 0, y: 20 });
gsap.set('.hero h1', { opacity: 0, y: 40 });
gsap.set('.hero .subtitle', { opacity: 0, y: 25 });
gsap.timeline({ defaults: { ease: 'expo.out' } })
.to('.hero .eyebrow', { opacity: 1, y: 0, duration: 0.7, delay: 0.3 })
.to('.hero h1', { opacity: 1, y: 0, duration: 0.9 }, '-=0.4')
.to('.hero .subtitle', { opacity: 1, y: 0, duration: 0.7 }, '-=0.5');
}
// --- Section Reveals ---
document.querySelectorAll('.section').forEach((section, i) => {
if (!reduced) {
gsap.set(section, { opacity: 0, y: 40 });
gsap.to(section, {
opacity: 1,
y: 0,
duration: 0.8,
ease: 'expo.out',
scrollTrigger: {
trigger: section,
start: 'top 75%',
toggleActions: 'play none none reverse',
},
});
}
});
document.querySelectorAll('.section h2').forEach((el) => {
if (!reduced) {
gsap.set(el, { opacity: 0, x: -20 });
gsap.to(el, {
opacity: 1,
x: 0,
duration: 0.7,
ease: 'expo.out',
scrollTrigger: { trigger: el, start: 'top 75%', toggleActions: 'play none none reverse' },
});
}
});
document.querySelectorAll('.section ul li').forEach((li, i) => {
if (!reduced) {
gsap.set(li, { opacity: 0, x: -15 });
gsap.to(li, {
opacity: 1,
x: 0,
duration: 0.5,
ease: 'expo.out',
delay: i * 0.06,
scrollTrigger: { trigger: li, start: 'top 75%', toggleActions: 'play none none reverse' },
});
}
});
// --- Motion Preference ---
window.addEventListener('motion-preference', (e) => {
if (e.detail.reduced) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
gsap.globalTimeline.paused(true);
} else {
gsap.globalTimeline.paused(false);
}
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Velocity-Aware Scroll + Speed Lines — stealthisdesign</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"gsap":"/vendor/gsap/index.js","gsap/ScrollTrigger":"/vendor/gsap/ScrollTrigger.js","gsap/SplitText":"/vendor/gsap/SplitText.js","gsap/Flip":"/vendor/gsap/Flip.js","gsap/ScrambleTextPlugin":"/vendor/gsap/ScrambleTextPlugin.js","gsap/TextPlugin":"/vendor/gsap/TextPlugin.js","gsap/all":"/vendor/gsap/all.js","gsap/":"/vendor/gsap/","lenis":"/vendor/lenis/dist/lenis.mjs","three":"/vendor/three/build/three.module.js","three/addons/":"/vendor/three/examples/jsm/"}}</script>
<style>html.lenis,
html.lenis body {
height: auto;
}
.lenis:not(.lenis-autoToggle).lenis-stopped {
overflow: clip;
}
.lenis [data-lenis-prevent],
.lenis [data-lenis-prevent-wheel],
.lenis [data-lenis-prevent-touch] {
overscroll-behavior: contain;
}
.lenis.lenis-smooth iframe {
pointer-events: none;
}
.lenis.lenis-autoToggle {
transition-property: overflow;
transition-duration: 1ms;
transition-behavior: allow-discrete;
}</style>
</head>
<body>
<canvas id="speed-canvas"></canvas>
<section class="hero">
<div class="content">
<span class="eyebrow">Demo 39</span>
<h1>Velocity-Aware Scroll</h1>
<p class="subtitle">Scroll faster to see speed lines intensify. The canvas layer reacts to scroll velocity — the faster you scroll, the stronger the visual feedback.</p>
</div>
</section>
<section class="section">
<div class="content">
<h2>Scroll Speed Detection</h2>
<p>Lenis provides real-time velocity data through its <code>scroll</code> event. We map that velocity to visual intensity — opacity, line count, and blur all scale with how fast you scroll.</p>
<p>Slow scrolling produces minimal effect. Fast scrolling creates a dramatic speed-lines tunnel on the Canvas overlay.</p>
</div>
</section>
<section class="section">
<div class="content">
<h2>Canvas Speed Lines</h2>
<p>A Canvas 2D layer sits fixed behind the page. On each animation frame, lines radiate from the center at lengths proportional to the scroll velocity magnitude.</p>
<p>The visual effect is inspired by motion blur in photography and the speed lines used in action manga. Faster scroll = more lines, higher opacity, longer streaks.</p>
</div>
</section>
<section class="section">
<div class="content">
<h2>Parallax Intensity</h2>
<p>Parallax layers in the background also react to velocity. Faster scrolling temporarily increases the parallax offset multiplier, creating a sense of dimensional acceleration through space.</p>
<p>This effect is commonly used in gaming landing pages, sportswear brands, and esports tournament sites.</p>
</div>
</section>
<section class="section">
<div class="content">
<h2>Performance</h2>
<p>The effect is optimized for 60fps using:</p>
<ul>
<li>Canvas <code>clearRect</code> + redraw on each rAF tick (no DOM mutations)</li>
<li>Velocity decay — speed lines fade naturally when scrolling slows</li>
<li>Line count cap to prevent overdraw on slow devices</li>
<li>Disabled entirely if <code>prefers-reduced-motion</code> is active</li>
</ul>
</div>
</section>
<section class="section">
<div class="content">
<h2>Implementation Details</h2>
<p>Access Lenis velocity via <code>lenis.on('scroll', ({ velocity }) => ...)</code>. The velocity value is a float between roughly -30 and +30 depending on scroll speed.</p>
<p>Map the absolute value to your visual parameters:</p>
<ul>
<li><code>lineCount</code> = <code>Math.min(maxLines, Math.abs(velocity) * 5)</code></li>
<li><code>lineLength</code> = <code>Math.abs(velocity) * 20</code> pixels</li>
<li><code>opacity</code> = <code>Math.min(0.8, Math.abs(velocity) * 0.05)</code></li>
</ul>
<a href="/" class="btn-back">Back to Showcase</a>
</div>
</section>
<script type="module" src="script.js"></script>
</body>
</html>