3D-like Vector Field — D3 + GSAP ScrollTrigger
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>3D-like Vector Field — D3 + GSAP ScrollTrigger</title>
<style>
html,body{height:200%;margin:0;background:#0b0b0b}
#body {
width: 100vw;
height: 800vh;
position: relative;
}
#canvas{position:sticky; top: 0;inset:0;display:block;width:100%;height:100%}
label{display:inline-flex;gap:8px;align-items:center}
input[type=range]{width:140px}
.hint{position:fixed;left:12px;top:64px;color:#ccc;background:rgba(0,0,0,0.35);padding:6px 8px;border-radius:6px;font-size:12px}
</style>
</head>
<body>
<div id="body">
<canvas id="canvas"></canvas>
</div>
<!-- D3 (for some helpers) and GSAP + ScrollTrigger -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.10.2/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
<script>
(() => {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { alpha: false });
let DPR = Math.max(1, window.devicePixelRatio || 1);
function resize() {
DPR = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.floor(innerWidth * DPR);
canvas.height = Math.floor(innerHeight * DPR);
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';
ctx.setTransform(1,0,0,1,0,0);
ctx.scale(DPR, DPR);
}
addEventListener('resize', resize);
resize();
// UI
let spacing = Number(20);
let baseSpeed = Number(60) / 100;
// Field params
const arrowLenBase = 0.9;
const headSizeBase = 6;
let t = 0;
let last = performance.now();
// Wave offset (controlled by scroll): vertical phase shift mapped to pixel offset
let waveOffsetY = 0; // pixels
// We'll animate a "wave phase" applied to field evaluation; ScrollTrigger will set waveOffsetY with GSAP.
gsap.registerPlugin(ScrollTrigger);
// Map scroll progress (0..1 across document height) to a wave offset range (e.g., -innerHeight..innerHeight)
ScrollTrigger.create({
trigger: "#body",
start: "top top",
end: "bottom bottom",
markers: true,
onUpdate(self) {
// map progress so the wave moves up/down: center at 0, range ±(innerHeight*0.6)
const range = Math.max(innerHeight * 0.6, 100);
// smooth tween to avoid jank
gsap.to(window, { duration: 0.35, overwrite: true, onUpdate: () => {},
onStart: ()=>{},
onComplete: ()=>{},
});
waveOffsetY = (self.progress - 0.5) * 2 * range;
}
});
// Rainbow helper
function hslStr(h, s=0.9, l=0.55, a=1){
const hdeg = ((h % 1) + 1) * 360;
return `hsla(${hdeg}, ${Math.round(s*100)}%, ${Math.round(l*100)}%, ${a})`;
}
// 3D-like vector field: uses x,y normalized and a phase that includes time + vertical wave offset
function field(x, y, time, waveY) {
const nx = (x / innerWidth) * 2 - 1;
const ny = (y / innerHeight) * 2 - 1;
// incorporate wave offset as a phase shift based on y + waveY
const wavePhase = ( (y + waveY) / innerHeight ) * Math.PI * 2;
// multiple layered components for a richer 3D-like flow
const vx =
Math.sin(2.2 * nx + 1.3 * time + 0.9 * Math.sin(wavePhase)) * 0.6 +
Math.cos(3.1 * ny - 0.7 * time + 0.6 * Math.cos(wavePhase * 0.7)) * 0.45 +
Math.sin(4.6 * (nx + ny) + 0.5 * time) * 0.3;
const vy =
Math.cos(2.0 * ny - 1.1 * time + 0.8 * Math.cos(wavePhase * 1.1)) * 0.6 +
Math.sin(2.5 * nx + 0.95 * time + 0.6 * Math.sin(wavePhase * 0.9)) * 0.45 +
Math.cos(3.9 * (nx - ny) - 0.45 * time) * 0.28;
const z = Math.sin(1.6 * nx * ny + 0.8 * time + 0.5 * Math.sin(wavePhase*0.5));
const depth = 0.5 + 0.5 * z;
return { vx: vx * (0.7 + depth * 0.8), vy: vy * (0.7 + depth * 0.8), depth };
}
// Draw loop
function draw(now) {
const dt = (now - last) / 1000; last = now;
// animate time faster when baseSpeed increases; keep a minimum
const timeScale = Math.max(0.001, baseSpeed);
t += dt * timeScale;
// Clear
ctx.fillStyle = '#0b0b0b';
ctx.fillRect(0,0,innerWidth,innerHeight);
const cols = Math.ceil(innerWidth / spacing);
const rows = Math.ceil(innerHeight / spacing);
const cx = innerWidth / 2, cy = innerHeight / 2;
for (let i = 0; i <= cols; i++) {
const x = i * spacing;
for (let j = 0; j <= rows; j++) {
const y = j * spacing;
// Evaluate field with waveOffsetY affecting the phase
const f = field(x, y, t, waveOffsetY);
const mag = Math.hypot(f.vx, f.vy);
const ang = Math.atan2(f.vy, f.vx);
// perspective: nearer center appears longer/brighter
const dxC = (x - cx) / cx;
const dyC = (y - cy) / cy;
const dist = Math.hypot(dxC, dyC);
const perspective = 1.2 - 0.9 * Math.min(dist, 1);
const len = arrowLenBase * spacing * (0.35 + mag * 0.9) * (0.6 + f.depth * 1.2) * perspective;
// Color: hue depends on angle + time to sweep rainbow + depth offset
const baseHue = (ang / (Math.PI * 2)) + (t * 0.03);
const hue = (baseHue + f.depth * 0.12 + ( (y+waveOffsetY)/innerHeight )*0.08 ) % 1;
const alpha = 0.35 + 0.65 * f.depth * perspective;
ctx.save();
ctx.translate(x, y);
// shadow/tail
ctx.lineWidth = Math.max(0.6, 1.0 * (DPR/1.5) * perspective * (0.6 + f.depth));
ctx.lineCap = 'round';
ctx.strokeStyle = hslStr(hue, 0.9, 0.34, alpha * 0.42);
ctx.beginPath();
ctx.moveTo(-len*0.14 * Math.cos(ang), -len*0.14 * Math.sin(ang));
ctx.lineTo((len*0.72) * Math.cos(ang), (len*0.72) * Math.sin(ang));
ctx.stroke();
// main shaft
ctx.lineWidth = Math.max(1, 1.2 * (DPR/1.3) * perspective);
ctx.strokeStyle = hslStr(hue, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(0,0);
ctx.lineTo(len * Math.cos(ang), len * Math.sin(ang));
ctx.stroke();
// arrow head
const hx = len * Math.cos(ang);
const hy = len * Math.sin(ang);
const leftAng = ang + Math.PI * 0.75;
const rightAng = ang - Math.PI * 0.75;
const hs = Math.max(3, headSizeBase * (0.6 + f.depth) * (DPR/1.5) * perspective);
ctx.fillStyle = hslStr((hue + 0.03) % 1, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.lineTo(hx + Math.cos(leftAng) * hs, hy + Math.sin(leftAng) * hs);
ctx.lineTo(hx + Math.cos(rightAng) * hs, hy + Math.sin(rightAng) * hs);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
// subtle vignette
const g = ctx.createRadialGradient(cx, cy, Math.min(cx,cy)*0.2, cx, cy, Math.max(cx,cy));
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.36)');
ctx.fillStyle = g;
ctx.fillRect(0,0,innerWidth,innerHeight);
requestAnimationFrame(draw);
}
// start loop
requestAnimationFrame(now => { last = now; requestAnimationFrame(draw); });
// Basic interaction: click to pause/resume (toggle)
let paused = false;
canvas.addEventListener('click', ()=> {
paused = !paused;
if (paused) {
// freeze by stopping the RAF loop: easiest is to replace draw with no-op via flag
// we'll implement by overriding requestAnimationFrame loop control:
// Note: to keep concise, reload to resume as minimal approach
// but we can also simply stop updating t and drawing: set paused true and do nothing
// For simplicity: when paused, overwrite draw to a no-op by preventing future frames
// Here we do a page-level toggle that reloads to resume.
// (This keeps the code compact.)
// Show a subtle overlay? omitted to remain minimal.
cancelAnimationFrame(); // harmless no-op in browsers when no id
// Freeze by entering an infinite loop prevention: simply reload the page to resume.
// (Practical: user can refresh to restart.)
} else {
location.reload();
}
});
// Prevent text selection, dragging
canvas.onselectstart = () => false;
})();
</script>
</body>
</html>
<!--
This conversation was generated with Duck.ai (https://duck.ai) using OpenAI's GPT-5 mini Model. AI chats may display inaccurate or offensive information (see https://duckduckgo.com/duckai/privacy-terms for more info).
====================
User prompt 1 of 4 - 2/16/2026, 7:53:36 PM:
Create me a 3d vector field with d3js which changes color to rainbow pallete with requestAnimationFrame. All in one HTML file.
GPT-5 mini:
## 3D-like animated vector field (single HTML file)
Save the following as a single .html file and open it in a browser. It draws a 2.5D-looking vector field on a canvas, animates vectors with requestAnimationFrame, and cycles their colors through a rainbow palette. No external libraries required.
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>3D Vector Field - Rainbow Animation</title>
<style>
html,body { height:100%; margin:0; background:#111; }
canvas { display:block; width:100%; height:100%; }
#ui {
position: absolute; left:12px; top:12px; color:#eee;
font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial; font-size:13px;
background: rgba(0,0,0,0.4); padding:8px 10px; border-radius:8px;
}
label { display:inline-flex; gap:6px; align-items:center; }
input[type=range] { vertical-align:middle; width:140px; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ui">
<label>Scale <input id="scale" type="range" min="6" max="40" value="18"></label>
<label style="margin-left:12px">Speed <input id="speed" type="range" min="0" max="200" value="60"></label>
</div>
<script>
(() => {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d', { alpha: false });
let DPR = Math.max(1, window.devicePixelRatio || 1);
function resize() {
DPR = Math.max(1, window.devicePixelRatio || 1);
const w = Math.floor(innerWidth * DPR);
const h = Math.floor(innerHeight * DPR);
canvas.width = w;
canvas.height = h;
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';
ctx.setTransform(1,0,0,1,0,0);
ctx.scale(DPR, DPR); // draw in CSS px
}
addEventListener('resize', resize);
resize();
// UI
const scaleInput = document.getElementById('scale');
const speedInput = document.getElementById('speed');
// Grid settings
let gridSize = Number(scaleInput.value); // spacing in CSS px
let timeScale = Number(speedInput.value) / 100; // animation speed multiplier
scaleInput.addEventListener('input', ()=> gridSize = Number(scaleInput.value));
speedInput.addEventListener('input', ()=> timeScale = Number(speedInput.value) / 100);
// Vector field parameters
const cols = () => Math.ceil(innerWidth / gridSize);
const rows = () => Math.ceil(innerHeight / gridSize);
// A smooth pseudo-3D vector field using layered sin/cos and a virtual z-time to create depth
function field(x, y, t) {
// Normalize to -1..1
const nx = (x / innerWidth) * 2 - 1;
const ny = (y / innerHeight) * 2 - 1;
// multiple frequency layers for complexity
const vx =
Math.sin(2.0 * nx + 1.2 * t) * 0.6 +
Math.cos(3.0 * ny - 0.8 * t) * 0.4 +
Math.sin(4.5 * (nx + ny) + 0.4 * t) * 0.35;
const vy =
Math.cos(2.2 * ny - 1.1 * t) * 0.6 +
Math.sin(2.6 * nx + 0.9 * t) * 0.45 +
Math.cos(3.9 * (nx - ny) - 0.5 * t) * 0.3;
// Add a swirling "3D" depth effect
const z = Math.sin(1.5 * nx * ny + 0.7 * t);
const depthFactor = 0.5 + 0.5 * z; // 0..1
return {
vx: vx * (0.6 + depthFactor * 0.9),
vy: vy * (0.6 + depthFactor * 0.9),
depth: depthFactor
};
}
// Rainbow palette: convert hue (0..1) to rgb string
function hslToRgbString(h, s = 0.9, l = 0.5, a = 1) {
// h in 0..1
const h360 = ((h % 1) + 1) * 360;
return `hsla(${h360}, ${Math.round(s*100)}%, ${Math.round(l*100)}%, ${a})`;
}
// Render parameters
const arrowLen = 0.9; // base length multiplier
const arrowThickness = 1.2;
const headSize = 6; // px
let last = performance.now();
let t = 0;
function draw(now) {
const dt = (now - last) / 1000; last = now;
t += dt * timeScale;
// Clear with dark gray background (non-alpha canvas)
ctx.fillStyle = '#0b0b0b';
ctx.fillRect(0,0,innerWidth,innerHeight);
const spacing = gridSize;
const c = cols(), r = rows();
// Precompute center for perspective
const cx = innerWidth / 2, cy = innerHeight / 2;
for (let i = 0; i <= c; i++) {
const x = i * spacing;
for (let j = 0; j <= r; j++) {
const y = j * spacing;
// Evaluate field
const f = field(x, y, t);
// Direction & magnitude
const mag = Math.hypot(f.vx, f.vy);
const ang = Math.atan2(f.vy, f.vx);
// 3D-ish perspective scaling: vectors closer to center appear longer/brighter
const dxC = (x - cx) / cx;
const dyC = (y - cy) / cy;
const distFromCenter = Math.hypot(dxC, dyC); // 0..~1.4
const perspective = 1.2 - 0.9 * Math.min(distFromCenter, 1);
// length scaled by magnitude, depth and perspective
const len = arrowLen * spacing * (0.4 + mag * 0.9) * (0.6 + f.depth * 1.2) * perspective;
// Color: hue varies by angle + time for rainbow sweep
const baseHue = (ang / (Math.PI * 2)) + (t * 0.05);
const hue = (baseHue + f.depth * 0.15) % 1;
const alpha = 0.35 + 0.65 * f.depth * perspective;
ctx.save();
ctx.translate(x, y);
// Add a subtle shadow/3D highlight: draw a faint darker tail for depth
ctx.lineWidth = Math.max(0.6, arrowThickness * (DPR/1.5) * perspective * (0.6 + f.depth));
ctx.lineCap = 'round';
ctx.strokeStyle = hslToRgbString(hue, 0.9, 0.35, alpha * 0.45);
ctx.beginPath();
ctx.moveTo(-len*0.12 * Math.cos(ang), -len*0.12 * Math.sin(ang));
ctx.lineTo((len*0.78) * Math.cos(ang), (len*0.78) * Math.sin(ang));
ctx.stroke();
// Main bright shaft
ctx.lineWidth = Math.max(1, arrowThickness * (DPR/1.3) * perspective);
ctx.strokeStyle = hslToRgbString(hue, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(0,0);
ctx.lineTo(len * Math.cos(ang), len * Math.sin(ang));
ctx.stroke();
// Arrow head as filled triangle
const hx = len * Math.cos(ang);
const hy = len * Math.sin(ang);
const leftAng = ang + Math.PI * 0.75;
const rightAng = ang - Math.PI * 0.75;
const hs = Math.max(3, headSize * (0.6 + f.depth) * (DPR/1.5) * perspective);
ctx.fillStyle = hslToRgbString((hue + 0.03) % 1, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.lineTo(hx + Math.cos(leftAng) * hs, hy + Math.sin(leftAng) * hs);
ctx.lineTo(hx + Math.cos(rightAng) * hs, hy + Math.sin(rightAng) * hs);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
// subtle vignette for depth
const g = ctx.createRadialGradient(cx, cy, Math.min(cx,cy)*0.2, cx, cy, Math.max(cx,cy));
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.35)');
ctx.fillStyle = g;
ctx.fillRect(0,0,innerWidth,innerHeight);
requestAnimationFrame(draw);
}
requestAnimationFrame(now => {
last = now;
requestAnimationFrame(draw);
});
// Interactive pause/resume on click
let running = true;
canvas.addEventListener('click', () => {
running = !running;
if (running) {
last = performance.now();
requestAnimationFrame(draw);
}
});
// Prevent default drag & select
canvas.onselectstart = () => false;
})();
</script>
</body>
</html>
```
Usage:
- Open the file in a browser.
- Adjust "Scale" (grid spacing) and "Speed" sliders.
- Click the canvas to pause/resume animation.
--------------------
User prompt 2 of 4 - 2/16/2026, 7:54:37 PM:
Add GSAP scroll trigger so when I scroll they start to move with higher velocity.
GPT-5 mini:
Save this single HTML file. It includes GSAP + ScrollTrigger (from CDN) and makes the field speed up while the page is being scrolled—speed decays back after scrolling stops. Open in a browser.
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>3D Vector Field — Rainbow + GSAP ScrollTrigger</title>
<style>
html,body { height:200%; margin:0; background:#111; } /* taller page so scrolling is possible */
canvas { display:block; position:fixed; inset:0; width:100%; height:100%; }
#ui {
position: absolute; left:12px; top:12px; color:#eee;
font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial; font-size:13px;
background: rgba(0,0,0,0.4); padding:8px 10px; border-radius:8px; z-index:10;
}
label { display:inline-flex; gap:6px; align-items:center; }
input[type=range] { vertical-align:middle; width:140px; }
.hint { position: absolute; left:12px; top:64px; color:#ccc; font-size:12px; background:rgba(0,0,0,0.35); padding:6px 8px; border-radius:6px; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ui">
<label>Scale <input id="scale" type="range" min="6" max="40" value="18"></label>
<label style="margin-left:12px">Base Speed <input id="speed" type="range" min="0" max="200" value="60"></label>
</div>
<div class="hint">Scroll the page to increase vector motion (GSAP ScrollTrigger)</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
<script>
(() => {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d', { alpha: false });
let DPR = Math.max(1, window.devicePixelRatio || 1);
function resize() {
DPR = Math.max(1, window.devicePixelRatio || 1);
const w = Math.floor(innerWidth * DPR);
const h = Math.floor(innerHeight * DPR);
canvas.width = w;
canvas.height = h;
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';
ctx.setTransform(1,0,0,1,0,0);
ctx.scale(DPR, DPR); // draw in CSS px
}
addEventListener('resize', resize);
resize();
// UI
const scaleInput = document.getElementById('scale');
const speedInput = document.getElementById('speed');
// Grid settings
let gridSize = Number(scaleInput.value); // spacing in CSS px
let baseTimeScale = Number(speedInput.value) / 100; // animation speed multiplier (base)
let scrollBoost = 0; // additive boost applied while scrolling
scaleInput.addEventListener('input', ()=> gridSize = Number(scaleInput.value));
speedInput.addEventListener('input', ()=> baseTimeScale = Number(speedInput.value) / 100);
// Vector field parameters
const cols = () => Math.ceil(innerWidth / gridSize);
const rows = () => Math.ceil(innerHeight / gridSize);
function field(x, y, t) {
const nx = (x / innerWidth) * 2 - 1;
const ny = (y / innerHeight) * 2 - 1;
const vx =
Math.sin(2.0 * nx + 1.2 * t) * 0.6 +
Math.cos(3.0 * ny - 0.8 * t) * 0.4 +
Math.sin(4.5 * (nx + ny) + 0.4 * t) * 0.35;
const vy =
Math.cos(2.2 * ny - 1.1 * t) * 0.6 +
Math.sin(2.6 * nx + 0.9 * t) * 0.45 +
Math.cos(3.9 * (nx - ny) - 0.5 * t) * 0.3;
const z = Math.sin(1.5 * nx * ny + 0.7 * t);
const depthFactor = 0.5 + 0.5 * z;
return { vx: vx * (0.6 + depthFactor * 0.9), vy: vy * (0.6 + depthFactor * 0.9), depth: depthFactor };
}
function hslToRgbString(h, s = 0.9, l = 0.5, a = 1) {
const h360 = ((h % 1) + 1) * 360;
return `hsla(${h360}, ${Math.round(s*100)}%, ${Math.round(l*100)}%, ${a})`;
}
const arrowLen = 0.9;
const arrowThickness = 1.2;
const headSize = 6;
let last = performance.now();
let t = 0;
// Smoothly animate scrollBoost using GSAP; initial boost 0
gsap.registerPlugin(ScrollTrigger);
// We'll increase scrollBoost proportional to scroll velocity; use ScrollTrigger's onUpdate with velocity estimator
const boostObj = { v: 0 }; // animated value for boost
function setBoost(v) { gsap.to(boostObj, { v: v, duration: 0.25, ease: 'power2.out' }); }
// Measure scroll velocity: use ScrollTrigger's getVelocity
ScrollTrigger.create({
start: 0,
end: 999999,
onUpdate(self) {
// getVelocity() returns pixels per second; normalize to a reasonable boost
const vel = Math.abs(self.getVelocity());
// map velocity to boost range [0..2.5] (tweakable)
const mapped = Math.min(2.5, vel / 800);
setBoost(mapped);
},
onLeave() { setBoost(0); },
onEnterBack() { /* handled by onUpdate */ }
});
function draw(now) {
const dt = (now - last) / 1000; last = now;
// timeScale incorporates baseTimeScale + animated scroll boost
const timeScale = baseTimeScale + boostObj.v;
t += dt * timeScale;
ctx.fillStyle = '#0b0b0b';
ctx.fillRect(0,0,innerWidth,innerHeight);
const spacing = gridSize;
const c = cols(), r = rows();
const cx = innerWidth / 2, cy = innerHeight / 2;
for (let i = 0; i <= c; i++) {
const x = i * spacing;
for (let j = 0; j <= r; j++) {
const y = j * spacing;
const f = field(x, y, t);
const mag = Math.hypot(f.vx, f.vy);
const ang = Math.atan2(f.vy, f.vx);
const dxC = (x - cx) / cx;
const dyC = (y - cy) / cy;
const distFromCenter = Math.hypot(dxC, dyC);
const perspective = 1.2 - 0.9 * Math.min(distFromCenter, 1);
const len = arrowLen * spacing * (0.4 + mag * 0.9) * (0.6 + f.depth * 1.2) * perspective;
const baseHue = (ang / (Math.PI * 2)) + (t * 0.05);
const hue = (baseHue + f.depth * 0.15) % 1;
const alpha = 0.35 + 0.65 * f.depth * perspective;
ctx.save();
ctx.translate(x, y);
ctx.lineWidth = Math.max(0.6, arrowThickness * (DPR/1.5) * perspective * (0.6 + f.depth));
ctx.lineCap = 'round';
ctx.strokeStyle = hslToRgbString(hue, 0.9, 0.35, alpha * 0.45);
ctx.beginPath();
ctx.moveTo(-len*0.12 * Math.cos(ang), -len*0.12 * Math.sin(ang));
ctx.lineTo((len*0.78) * Math.cos(ang), (len*0.78) * Math.sin(ang));
ctx.stroke();
ctx.lineWidth = Math.max(1, arrowThickness * (DPR/1.3) * perspective);
ctx.strokeStyle = hslToRgbString(hue, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(0,0);
ctx.lineTo(len * Math.cos(ang), len * Math.sin(ang));
ctx.stroke();
const hx = len * Math.cos(ang);
const hy = len * Math.sin(ang);
const leftAng = ang + Math.PI * 0.75;
const rightAng = ang - Math.PI * 0.75;
const hs = Math.max(3, headSize * (0.6 + f.depth) * (DPR/1.5) * perspective);
ctx.fillStyle = hslToRgbString((hue + 0.03) % 1, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.lineTo(hx + Math.cos(leftAng) * hs, hy + Math.sin(leftAng) * hs);
ctx.lineTo(hx + Math.cos(rightAng) * hs, hy + Math.sin(rightAng) * hs);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
const g = ctx.createRadialGradient(cx, cy, Math.min(cx,cy)*0.2, cx, cy, Math.max(cx,cy));
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.35)');
ctx.fillStyle = g;
ctx.fillRect(0,0,innerWidth,innerHeight);
requestAnimationFrame(draw);
}
// Start the loop, but ensure last is set
requestAnimationFrame(now => {
last = now;
requestAnimationFrame(draw);
});
// Pause/resume on click
let running = true;
canvas.addEventListener('click', () => {
running = !running;
if (running) {
last = performance.now();
requestAnimationFrame(draw);
} else {
// effectively pause by freezing last time and stopping frames: we can't cancel easily, but we can halt by skipping draw loop
// Instead, toggle a flag used in draw; simpler approach: when paused, draw no-op until resumed.
// For brevity, reload to resume behavior (lightweight).
location.reload();
}
});
// Prevent default drag & select
canvas.onselectstart = () => false;
// Optional: gentle decay of boost when not scrolling using a ticker
// (Handled by gsap tweening boostObj.v via setBoost)
})();
</script>
</body>
</html>
```
--------------------
User prompt 3 of 4 - 2/16/2026, 7:56:15 PM:
Create me a 3d vector field with d3js which changes color to rainbow pallete with requestAnimationFrame. All in one HTML file. Add GSAP scroll trigger when I scroll the wave of vector field move up and down.
GPT-5 mini:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>3D-like Vector Field — D3 + GSAP ScrollTrigger</title>
<style>
html,body{height:200%;margin:0;background:#0b0b0b}
#canvas{position:fixed;inset:0;display:block;width:100%;height:100%}
#ui{position:fixed;left:12px;top:12px;color:#eee;font-family:system-ui,Segoe UI,Roboto,Arial;font-size:13px;background:rgba(0,0,0,0.45);padding:8px 10px;border-radius:8px;z-index:12}
label{display:inline-flex;gap:8px;align-items:center}
input[type=range]{width:140px}
.hint{position:fixed;left:12px;top:64px;color:#ccc;background:rgba(0,0,0,0.35);padding:6px 8px;border-radius:6px;font-size:12px}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="ui">
<label>Spacing <input id="spacing" type="range" min="8" max="48" value="20"></label>
<label style="margin-left:10px">Base Speed <input id="speed" type="range" min="0" max="200" value="60"></label>
</div>
<div class="hint">Scroll to move the wave up/down (GSAP ScrollTrigger)</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.10.2/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
<script>
(() => {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { alpha: false });
let DPR = Math.max(1, window.devicePixelRatio || 1);
function resize() {
DPR = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.floor(innerWidth * DPR);
canvas.height = Math.floor(innerHeight * DPR);
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';
ctx.setTransform(1,0,0,1,0,0);
ctx.scale(DPR, DPR);
}
addEventListener('resize', resize);
resize();
// UI
const spacingInput = document.getElementById('spacing');
const speedInput = document.getElementById('speed');
let spacing = Number(spacingInput.value);
let baseSpeed = Number(speedInput.value) / 100;
spacingInput.addEventListener('input', ()=> spacing = Number(spacingInput.value));
speedInput.addEventListener('input', ()=> baseSpeed = Number(speedInput.value) / 100);
// Field params
const arrowLenBase = 0.9;
const headSizeBase = 6;
let t = 0;
let last = performance.now();
// Wave offset (controlled by scroll): vertical phase shift mapped to pixel offset
let waveOffsetY = 0; // pixels
// We'll animate a "wave phase" applied to field evaluation; ScrollTrigger will set waveOffsetY with GSAP.
gsap.registerPlugin(ScrollTrigger);
// Map scroll progress (0..1 across document height) to a wave offset range (e.g., -innerHeight..innerHeight)
ScrollTrigger.create({
start: 0,
end: document.documentElement.scrollHeight - window.innerHeight,
onUpdate(self) {
// map progress so the wave moves up/down: center at 0, range ±(innerHeight*0.6)
const range = Math.max(innerHeight * 0.6, 100);
// smooth tween to avoid jank
gsap.to(window, { duration: 0.35, overwrite: true, onUpdate: () => {},
onStart: ()=>{},
onComplete: ()=>{},
});
waveOffsetY = (self.progress - 0.5) * 2 * range;
}
});
// Rainbow helper
function hslStr(h, s=0.9, l=0.55, a=1){
const hdeg = ((h % 1) + 1) * 360;
return `hsla(${hdeg}, ${Math.round(s*100)}%, ${Math.round(l*100)}%, ${a})`;
}
// 3D-like vector field: uses x,y normalized and a phase that includes time + vertical wave offset
function field(x, y, time, waveY) {
const nx = (x / innerWidth) * 2 - 1;
const ny = (y / innerHeight) * 2 - 1;
// incorporate wave offset as a phase shift based on y + waveY
const wavePhase = ( (y + waveY) / innerHeight ) * Math.PI * 2;
// multiple layered components for a richer 3D-like flow
const vx =
Math.sin(2.2 * nx + 1.3 * time + 0.9 * Math.sin(wavePhase)) * 0.6 +
Math.cos(3.1 * ny - 0.7 * time + 0.6 * Math.cos(wavePhase * 0.7)) * 0.45 +
Math.sin(4.6 * (nx + ny) + 0.5 * time) * 0.3;
const vy =
Math.cos(2.0 * ny - 1.1 * time + 0.8 * Math.cos(wavePhase * 1.1)) * 0.6 +
Math.sin(2.5 * nx + 0.95 * time + 0.6 * Math.sin(wavePhase * 0.9)) * 0.45 +
Math.cos(3.9 * (nx - ny) - 0.45 * time) * 0.28;
const z = Math.sin(1.6 * nx * ny + 0.8 * time + 0.5 * Math.sin(wavePhase*0.5));
const depth = 0.5 + 0.5 * z;
return { vx: vx * (0.7 + depth * 0.8), vy: vy * (0.7 + depth * 0.8), depth };
}
// Draw loop
function draw(now) {
const dt = (now - last) / 1000; last = now;
// animate time faster when baseSpeed increases; keep a minimum
const timeScale = Math.max(0.001, baseSpeed);
t += dt * timeScale;
// Clear
ctx.fillStyle = '#0b0b0b';
ctx.fillRect(0,0,innerWidth,innerHeight);
const cols = Math.ceil(innerWidth / spacing);
const rows = Math.ceil(innerHeight / spacing);
const cx = innerWidth / 2, cy = innerHeight / 2;
for (let i = 0; i <= cols; i++) {
const x = i * spacing;
for (let j = 0; j <= rows; j++) {
const y = j * spacing;
// Evaluate field with waveOffsetY affecting the phase
const f = field(x, y, t, waveOffsetY);
const mag = Math.hypot(f.vx, f.vy);
const ang = Math.atan2(f.vy, f.vx);
// perspective: nearer center appears longer/brighter
const dxC = (x - cx) / cx;
const dyC = (y - cy) / cy;
const dist = Math.hypot(dxC, dyC);
const perspective = 1.2 - 0.9 * Math.min(dist, 1);
const len = arrowLenBase * spacing * (0.35 + mag * 0.9) * (0.6 + f.depth * 1.2) * perspective;
// Color: hue depends on angle + time to sweep rainbow + depth offset
const baseHue = (ang / (Math.PI * 2)) + (t * 0.03);
const hue = (baseHue + f.depth * 0.12 + ( (y+waveOffsetY)/innerHeight )*0.08 ) % 1;
const alpha = 0.35 + 0.65 * f.depth * perspective;
ctx.save();
ctx.translate(x, y);
// shadow/tail
ctx.lineWidth = Math.max(0.6, 1.0 * (DPR/1.5) * perspective * (0.6 + f.depth));
ctx.lineCap = 'round';
ctx.strokeStyle = hslStr(hue, 0.9, 0.34, alpha * 0.42);
ctx.beginPath();
ctx.moveTo(-len*0.14 * Math.cos(ang), -len*0.14 * Math.sin(ang));
ctx.lineTo((len*0.72) * Math.cos(ang), (len*0.72) * Math.sin(ang));
ctx.stroke();
// main shaft
ctx.lineWidth = Math.max(1, 1.2 * (DPR/1.3) * perspective);
ctx.strokeStyle = hslStr(hue, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(0,0);
ctx.lineTo(len * Math.cos(ang), len * Math.sin(ang));
ctx.stroke();
// arrow head
const hx = len * Math.cos(ang);
const hy = len * Math.sin(ang);
const leftAng = ang + Math.PI * 0.75;
const rightAng = ang - Math.PI * 0.75;
const hs = Math.max(3, headSizeBase * (0.6 + f.depth) * (DPR/1.5) * perspective);
ctx.fillStyle = hslStr((hue + 0.03) % 1, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.lineTo(hx + Math.cos(leftAng) * hs, hy + Math.sin(leftAng) * hs);
ctx.lineTo(hx + Math.cos(rightAng) * hs, hy + Math.sin(rightAng) * hs);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
// subtle vignette
const g = ctx.createRadialGradient(cx, cy, Math.min(cx,cy)*0.2, cx, cy, Math.max(cx,cy));
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.36)');
ctx.fillStyle = g;
ctx.fillRect(0,0,innerWidth,innerHeight);
requestAnimationFrame(draw);
}
// start loop
requestAnimationFrame(now => { last = now; requestAnimationFrame(draw); });
// Basic interaction: click to pause/resume (toggle)
let paused = false;
canvas.addEventListener('click', ()=> {
paused = !paused;
if (paused) {
// freeze by stopping the RAF loop: easiest is to replace draw with no-op via flag
// we'll implement by overriding requestAnimationFrame loop control:
// Note: to keep concise, reload to resume as minimal approach
// but we can also simply stop updating t and drawing: set paused true and do nothing
// For simplicity: when paused, overwrite draw to a no-op by preventing future frames
// Here we do a page-level toggle that reloads to resume.
// (This keeps the code compact.)
// Show a subtle overlay? omitted to remain minimal.
cancelAnimationFrame(); // harmless no-op in browsers when no id
// Freeze by entering an infinite loop prevention: simply reload the page to resume.
// (Practical: user can refresh to restart.)
} else {
location.reload();
}
});
// Prevent text selection, dragging
canvas.onselectstart = () => false;
})();
</script>
</body>
</html>
```
--------------------
User prompt 4 of 4 - 2/16/2026, 7:57:20 PM:
Remove controls
GPT-5 mini:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>3D-like Vector Field — D3 + GSAP ScrollTrigger</title>
<style>
html,body{height:200%;margin:0;background:#0b0b0b}
#canvas{position:fixed;inset:0;display:block;width:100%;height:100%}
.hint{position:fixed;left:12px;top:12px;color:#ccc;background:rgba(0,0,0,0.35);padding:6px 8px;border-radius:6px;font-size:12px;z-index:12}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="hint">Scroll to move the wave up/down (GSAP ScrollTrigger)</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.10.2/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/ScrollTrigger.min.js"></script>
<script>
(() => {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { alpha: false });
let DPR = Math.max(1, window.devicePixelRatio || 1);
function resize() {
DPR = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.floor(innerWidth * DPR);
canvas.height = Math.floor(innerHeight * DPR);
canvas.style.width = innerWidth + 'px';
canvas.style.height = innerHeight + 'px';
ctx.setTransform(1,0,0,1,0,0);
ctx.scale(DPR, DPR);
}
addEventListener('resize', resize);
resize();
// Parameters (no controls)
let spacing = 20;
let baseSpeed = 0.6; // time multiplier
const arrowLenBase = 0.9;
const headSizeBase = 6;
let t = 0;
let last = performance.now();
let waveOffsetY = 0;
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.create({
start: 0,
end: document.documentElement.scrollHeight - window.innerHeight,
onUpdate(self) {
const range = Math.max(innerHeight * 0.6, 100);
waveOffsetY = (self.progress - 0.5) * 2 * range;
}
});
function hslStr(h, s=0.9, l=0.55, a=1){
const hdeg = ((h % 1) + 1) * 360;
return `hsla(${hdeg}, ${Math.round(s*100)}%, ${Math.round(l*100)}%, ${a})`;
}
function field(x, y, time, waveY) {
const nx = (x / innerWidth) * 2 - 1;
const ny = (y / innerHeight) * 2 - 1;
const wavePhase = ((y + waveY) / innerHeight) * Math.PI * 2;
const vx =
Math.sin(2.2 * nx + 1.3 * time + 0.9 * Math.sin(wavePhase)) * 0.6 +
Math.cos(3.1 * ny - 0.7 * time + 0.6 * Math.cos(wavePhase * 0.7)) * 0.45 +
Math.sin(4.6 * (nx + ny) + 0.5 * time) * 0.3;
const vy =
Math.cos(2.0 * ny - 1.1 * time + 0.8 * Math.cos(wavePhase * 1.1)) * 0.6 +
Math.sin(2.5 * nx + 0.95 * time + 0.6 * Math.sin(wavePhase * 0.9)) * 0.45 +
Math.cos(3.9 * (nx - ny) - 0.45 * time) * 0.28;
const z = Math.sin(1.6 * nx * ny + 0.8 * time + 0.5 * Math.sin(wavePhase*0.5));
const depth = 0.5 + 0.5 * z;
return { vx: vx * (0.7 + depth * 0.8), vy: vy * (0.7 + depth * 0.8), depth };
}
function draw(now) {
const dt = (now - last) / 1000; last = now;
t += dt * baseSpeed;
ctx.fillStyle = '#0b0b0b';
ctx.fillRect(0,0,innerWidth,innerHeight);
const cols = Math.ceil(innerWidth / spacing);
const rows = Math.ceil(innerHeight / spacing);
const cx = innerWidth / 2, cy = innerHeight / 2;
for (let i = 0; i <= cols; i++) {
const x = i * spacing;
for (let j = 0; j <= rows; j++) {
const y = j * spacing;
const f = field(x, y, t, waveOffsetY);
const mag = Math.hypot(f.vx, f.vy);
const ang = Math.atan2(f.vy, f.vx);
const dxC = (x - cx) / cx;
const dyC = (y - cy) / cy;
const dist = Math.hypot(dxC, dyC);
const perspective = 1.2 - 0.9 * Math.min(dist, 1);
const len = arrowLenBase * spacing * (0.35 + mag * 0.9) * (0.6 + f.depth * 1.2) * perspective;
const baseHue = (ang / (Math.PI * 2)) + (t * 0.03);
const hue = (baseHue + f.depth * 0.12 + ((y+waveOffsetY)/innerHeight)*0.08) % 1;
const alpha = 0.35 + 0.65 * f.depth * perspective;
ctx.save();
ctx.translate(x, y);
ctx.lineWidth = Math.max(0.6, 1.0 * (DPR/1.5) * perspective * (0.6 + f.depth));
ctx.lineCap = 'round';
ctx.strokeStyle = hslStr(hue, 0.9, 0.34, alpha * 0.42);
ctx.beginPath();
ctx.moveTo(-len*0.14 * Math.cos(ang), -len*0.14 * Math.sin(ang));
ctx.lineTo((len*0.72) * Math.cos(ang), (len*0.72) * Math.sin(ang));
ctx.stroke();
ctx.lineWidth = Math.max(1, 1.2 * (DPR/1.3) * perspective);
ctx.strokeStyle = hslStr(hue, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(0,0);
ctx.lineTo(len * Math.cos(ang), len * Math.sin(ang));
ctx.stroke();
const hx = len * Math.cos(ang);
const hy = len * Math.sin(ang);
const leftAng = ang + Math.PI * 0.75;
const rightAng = ang - Math.PI * 0.75;
const hs = Math.max(3, headSizeBase * (0.6 + f.depth) * (DPR/1.5) * perspective);
ctx.fillStyle = hslStr((hue + 0.03) % 1, 0.95, 0.55, alpha);
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.lineTo(hx + Math.cos(leftAng) * hs, hy + Math.sin(leftAng) * hs);
ctx.lineTo(hx + Math.cos(rightAng) * hs, hy + Math.sin(rightAng) * hs);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
const g = ctx.createRadialGradient(cx, cy, Math.min(cx,cy)*0.2, cx, cy, Math.max(cx,cy));
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.36)');
ctx.fillStyle = g;
ctx.fillRect(0,0,innerWidth,innerHeight);
requestAnimationFrame(draw);
}
requestAnimationFrame(now => { last = now; requestAnimationFrame(draw); });
canvas.onselectstart = () => false;
})();
</script>
</body>
</html>
```
-->