From adb56b3dabeb8d0a2fbe34d4c04b876cdfe52d21 Mon Sep 17 00:00:00 2001 From: Saksham Jain Date: Sat, 7 Mar 2026 22:48:46 +0530 Subject: [PATCH 1/5] feat: add BackgroundTile component and it's colours in design system --- src/app/[locale]/globals.css | 13 +- src/components/ui/BackgroundTile.tsx | 187 +++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 src/components/ui/BackgroundTile.tsx diff --git a/src/app/[locale]/globals.css b/src/app/[locale]/globals.css index 842e5aa..1def934 100644 --- a/src/app/[locale]/globals.css +++ b/src/app/[locale]/globals.css @@ -36,6 +36,11 @@ --button-primary-hover-bg: var(--color-neutral-50); --button-primary-hover-border: #121212; --button-subtle-bg: #9797971e; + + /* Background Tile Colors */ + --tile-bg: rgba(227, 226, 246); + --tile-stroke: rgba(80, 80, 80, 0.1); + --line-color: rgba(0, 0, 0, 0.05); } /* DARK MODE */ @@ -64,6 +69,11 @@ --button-primary-hover-bg: var(--color-neutral-100); --button-primary-hover-border: var(--color-neutral-100); --button-subtle-bg: #bbbbbb0e; + + /* Background Tile Colors */ + --tile-bg: rgba(30, 29, 28); + --tile-stroke: rgba(80, 80, 80, 0.5); + --line-color: rgba(0, 0, 0, 0.2); } /* --- GLOBAL STYLES --- */ @@ -96,5 +106,4 @@ body { /* Dark Mode: Icon flips to White */ :is(.dark .theme-icon) { filter: invert(1) brightness(100); -} - +} \ No newline at end of file diff --git a/src/components/ui/BackgroundTile.tsx b/src/components/ui/BackgroundTile.tsx new file mode 100644 index 0000000..e49444c --- /dev/null +++ b/src/components/ui/BackgroundTile.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef } from "react"; + +// Stable random values generated once at module level — fixes "impure function in render" warning +const PHASES = Array.from({ length: 6 }, () => Math.random() * Math.PI * 2); +const FREQS = Array.from({ length: 6 }, (_, i) => 0.3 + i * 0.13); + +interface BackgroundTileProps { + /** Height of the tile in viewport height units. Defaults to 50. */ + heightVh?: number; +} + +const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { + const canvasRef = useRef(null); + const rafRef = useRef(null); + const isRunning = useRef(false); + const timeRef = useRef(0); + const ampRef = useRef(0); // target amplitude + const ampSmoothed = useRef(0); // smoothed amplitude (what's actually rendered) + const stopTimer = useRef | null>(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // ── resize ──────────────────────────────────────────────────────── + const setSize = () => { + const dpr = window.devicePixelRatio || 1; + canvas.width = canvas.offsetWidth * dpr; + canvas.height = canvas.offsetHeight * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + setSize(); + const ro = new ResizeObserver(setSize); + ro.observe(canvas); + + // ── render one frame ────────────────────────────────────────────── + const render = () => { + const w = canvas.offsetWidth; + const h = canvas.offsetHeight; + const amp = ampSmoothed.current; + const t = timeRef.current; + + ctx.clearRect(0, 0, w, h); + + const N = 6; + const pts = Array.from({ length: N }, (_, i) => { + const w1 = Math.sin(t * FREQS[i] + PHASES[i]) * 0.50; + const w2 = Math.sin(t * FREQS[i] * 0.43 + PHASES[i] * 1.77) * 0.30; + const w3 = Math.cos(t * FREQS[i] * 0.27 + PHASES[i] * 0.85) * 0.20; + return { + x: (i / (N - 1)) * w, + y: h * 0.5 + (w1 + w2 + w3) * h * amp * 0.95, + }; + }); + + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + + for (let i = 0; i < N - 1; i++) { + const p0 = pts[Math.max(0, i - 1)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(N - 1, i + 2)]; + const k = 0.5; + ctx.bezierCurveTo( + p1.x + (p2.x - p0.x) * k / 3, + p1.y + (p2.y - p0.y) * k / 3, + p2.x - (p3.x - p1.x) * k / 3, + p2.y - (p3.y - p1.y) * k / 3, + p2.x, p2.y, + ); + } + + ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue("--line-color").trim(); + ctx.lineWidth = 14; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.stroke(); + }; + + render(); // initial flat line + + // ── loop — only alive while scrolling ───────────────────────────── + const tick = () => { + if (!isRunning.current) { + // Smoothly finish lerping to the frozen target, then stop + const target = ampRef.current; + const current = ampSmoothed.current; + ampSmoothed.current += (target - current) * 0.18; + + if (Math.abs(ampSmoothed.current - target) > 0.001) { + render(); + rafRef.current = requestAnimationFrame(tick); + } else { + ampSmoothed.current = target; // snap to exact target + render(); + rafRef.current = null; + } + return; + } + + timeRef.current += 0.055; + + // Lerp smoothed amp toward target — fast rise + const target = ampRef.current; + const current = ampSmoothed.current; + ampSmoothed.current += (target - current) * 0.18; + + render(); + rafRef.current = requestAnimationFrame(tick); + }; + + const startLoop = () => { + if (!isRunning.current) { + isRunning.current = true; + if (rafRef.current === null) { + rafRef.current = requestAnimationFrame(tick); + } + } + }; + + const stopLoop = () => { + // Stop animating time — amplitude stays frozen at current ampRef value + isRunning.current = false; + }; + + // ── scroll / wheel ───────────────────────────────────────────────── + const onWheel = (e: WheelEvent) => { + const vel = Math.min(Math.abs(e.deltaY) / 60, 1); + ampRef.current = 0.18 + vel * 0.74; + startLoop(); + if (stopTimer.current) clearTimeout(stopTimer.current); + stopTimer.current = setTimeout(stopLoop, 80); + }; + + const onScroll = () => { + ampRef.current = 0.55; + startLoop(); + if (stopTimer.current) clearTimeout(stopTimer.current); + stopTimer.current = setTimeout(stopLoop, 80); + }; + + window.addEventListener("wheel", onWheel, { passive: true }); + window.addEventListener("scroll", onScroll, { passive: true }); + canvas.addEventListener("wheel", onWheel, { passive: true }); + + return () => { + stopLoop(); + ro.disconnect(); + if (rafRef.current) cancelAnimationFrame(rafRef.current); + if (stopTimer.current) clearTimeout(stopTimer.current); + window.removeEventListener("wheel", onWheel); + window.removeEventListener("scroll", onScroll); + canvas.removeEventListener("wheel", onWheel); + }; + }, []); + + return ( +
+ +
+ ); +}; + +export default BackgroundTile; \ No newline at end of file From f3bb348ee39b1142d824d3042efbcaf3b13b6483 Mon Sep 17 00:00:00 2001 From: Saksham Jain Date: Sun, 8 Mar 2026 03:28:15 +0530 Subject: [PATCH 2/5] fix: address CodeRabbit feedback --- src/app/[locale]/globals.css | 17 ++++++------ src/app/[locale]/page.tsx | 3 ++- src/components/ui/BackgroundTile.tsx | 39 +++++++++++++++++++++------- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/app/[locale]/globals.css b/src/app/[locale]/globals.css index 1def934..f960a22 100644 --- a/src/app/[locale]/globals.css +++ b/src/app/[locale]/globals.css @@ -14,7 +14,7 @@ :root { /* Surface Colors */ --background: #F5F5F5; - --nav-background: rgba(245, 245, 245, 0.8); /* Added for glassmorphism */ + --nav-background: rgb(245, 245, 245, 0.8); /* Added for glassmorphism */ /* Text Colors */ --foreground: #1C1C1C; @@ -38,16 +38,16 @@ --button-subtle-bg: #9797971e; /* Background Tile Colors */ - --tile-bg: rgba(227, 226, 246); - --tile-stroke: rgba(80, 80, 80, 0.1); - --line-color: rgba(0, 0, 0, 0.05); + --tile-bg: rgb(227, 226, 246); + --tile-stroke: rgb(80, 80, 80, 0.1); + --line-color: rgb(0, 0, 0, 0.05); } /* DARK MODE */ .dark { /* Surface Colors */ --background: #101010; - --nav-background: rgba(16, 16, 16, 0.8); /* Added for glassmorphism */ + --nav-background: rgb(16, 16, 16, 0.8); /* Added for glassmorphism */ /* Text Colors */ --foreground: #D3D3D3; @@ -71,10 +71,9 @@ --button-subtle-bg: #bbbbbb0e; /* Background Tile Colors */ - --tile-bg: rgba(30, 29, 28); - --tile-stroke: rgba(80, 80, 80, 0.5); - --line-color: rgba(0, 0, 0, 0.2); -} + --tile-bg: rgb(30, 29, 28); + --tile-stroke: rgb(80, 80, 80, 0.5); + --line-color: rgb(0, 0, 0, 0.2);} /* --- GLOBAL STYLES --- */ body { diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 67f2d5a..4599025 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import BackgroundTile from '@/components/ui/BackgroundTile'; import { useTranslations } from 'next-intl'; import dynamic from 'next/dynamic'; @@ -12,7 +13,7 @@ import dynamic from 'next/dynamic'; export default function Home() { const t = useTranslations('Home'); return ( -
+

{t('title')}

{t('subtitle')}

diff --git a/src/components/ui/BackgroundTile.tsx b/src/components/ui/BackgroundTile.tsx index e49444c..1697599 100644 --- a/src/components/ui/BackgroundTile.tsx +++ b/src/components/ui/BackgroundTile.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useRef } from "react"; // Stable random values generated once at module level — fixes "impure function in render" warning @@ -10,13 +12,14 @@ interface BackgroundTileProps { } const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { - const canvasRef = useRef(null); - const rafRef = useRef(null); - const isRunning = useRef(false); - const timeRef = useRef(0); - const ampRef = useRef(0); // target amplitude - const ampSmoothed = useRef(0); // smoothed amplitude (what's actually rendered) - const stopTimer = useRef | null>(null); + const canvasRef = useRef(null); + const rafRef = useRef(null); + const lineColorRef = useRef(""); + const isRunning = useRef(false); + const timeRef = useRef(0); + const ampRef = useRef(0); // target amplitude + const ampSmoothed = useRef(0); // smoothed amplitude (what's actually rendered) + const stopTimer = useRef | null>(null); useEffect(() => { const canvas = canvasRef.current; @@ -25,6 +28,23 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { const ctx = canvas.getContext("2d"); if (!ctx) return; + // ── cache line color, refresh on theme changes ──────────────────── + const updateLineColor = () => { + lineColorRef.current = getComputedStyle(document.documentElement) + .getPropertyValue("--line-color") + .trim(); + }; + updateLineColor(); + + const themeObserver = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.attributeName === "class" || m.attributeName === "data-theme") { + updateLineColor(); + } + } + }); + themeObserver.observe(document.documentElement, { attributes: true }); + // ── resize ──────────────────────────────────────────────────────── const setSize = () => { const dpr = window.devicePixelRatio || 1; @@ -74,7 +94,7 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { ); } - ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue("--line-color").trim(); + ctx.strokeStyle = lineColorRef.current; // ← cached, not computed per-frame ctx.lineWidth = 14; ctx.lineJoin = "round"; ctx.lineCap = "round"; @@ -145,16 +165,15 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { window.addEventListener("wheel", onWheel, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true }); - canvas.addEventListener("wheel", onWheel, { passive: true }); return () => { stopLoop(); ro.disconnect(); + themeObserver.disconnect(); if (rafRef.current) cancelAnimationFrame(rafRef.current); if (stopTimer.current) clearTimeout(stopTimer.current); window.removeEventListener("wheel", onWheel); window.removeEventListener("scroll", onScroll); - canvas.removeEventListener("wheel", onWheel); }; }, []); From d217bf54a9fbb15e1847b5d9c2b37cd81e46be47 Mon Sep 17 00:00:00 2001 From: Saksham Jain Date: Sun, 8 Mar 2026 03:41:20 +0530 Subject: [PATCH 3/5] fix: address CodeRabbit feedback 2 --- src/app/[locale]/page.tsx | 1 - src/components/ui/BackgroundTile.tsx | 37 +++++++++++++++++++--------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 4599025..46f1c67 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,6 +1,5 @@ 'use client'; -import BackgroundTile from '@/components/ui/BackgroundTile'; import { useTranslations } from 'next-intl'; import dynamic from 'next/dynamic'; diff --git a/src/components/ui/BackgroundTile.tsx b/src/components/ui/BackgroundTile.tsx index 1697599..cf85266 100644 --- a/src/components/ui/BackgroundTile.tsx +++ b/src/components/ui/BackgroundTile.tsx @@ -52,9 +52,7 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { canvas.height = canvas.offsetHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; - setSize(); - const ro = new ResizeObserver(setSize); - ro.observe(canvas); + setSize(); // initial sizing before first render // ── render one frame ────────────────────────────────────────────── const render = () => { @@ -94,7 +92,13 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { ); } - ctx.strokeStyle = lineColorRef.current; // ← cached, not computed per-frame + const gradient = ctx.createLinearGradient(0, 0, w, 0); + gradient.addColorStop(0, "transparent"); + gradient.addColorStop(0.12, lineColorRef.current); + gradient.addColorStop(0.88, lineColorRef.current); + gradient.addColorStop(1, "transparent"); + + ctx.strokeStyle = gradient; ctx.lineWidth = 14; ctx.lineJoin = "round"; ctx.lineCap = "round"; @@ -103,6 +107,10 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { render(); // initial flat line + // ── ResizeObserver — registered after render() is defined ───────── + const ro = new ResizeObserver(() => { setSize(); render(); }); + ro.observe(canvas); + // ── loop — only alive while scrolling ───────────────────────────── const tick = () => { if (!isRunning.current) { @@ -148,19 +156,26 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { }; // ── scroll / wheel ───────────────────────────────────────────────── - const onWheel = (e: WheelEvent) => { - const vel = Math.min(Math.abs(e.deltaY) / 60, 1); - ampRef.current = 0.18 + vel * 0.74; + let lastWheelTime = 0; + + const triggerScroll = (amp: number) => { + ampRef.current = amp; startLoop(); if (stopTimer.current) clearTimeout(stopTimer.current); stopTimer.current = setTimeout(stopLoop, 80); }; + const onWheel = (e: WheelEvent) => { + lastWheelTime = Date.now(); + const vel = Math.min(Math.abs(e.deltaY) / 60, 1); + triggerScroll(0.18 + vel * 0.74); + }; + const onScroll = () => { - ampRef.current = 0.55; - startLoop(); - if (stopTimer.current) clearTimeout(stopTimer.current); - stopTimer.current = setTimeout(stopLoop, 80); + // Suppress if a wheel event fired within the last 100 ms — wheel already + // set a velocity-based amplitude that's more accurate than the fallback. + if (Date.now() - lastWheelTime < 100) return; + triggerScroll(0.55); }; window.addEventListener("wheel", onWheel, { passive: true }); From bcd47b486ac7f7ef30b8699371b9c4a6d8002c72 Mon Sep 17 00:00:00 2001 From: Saksham Jain Date: Sun, 8 Mar 2026 12:38:55 +0530 Subject: [PATCH 4/5] fix:better approach to BackgroundTile height adjustment --- src/components/ui/BackgroundTile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/BackgroundTile.tsx b/src/components/ui/BackgroundTile.tsx index cf85266..a42c2a7 100644 --- a/src/components/ui/BackgroundTile.tsx +++ b/src/components/ui/BackgroundTile.tsx @@ -199,10 +199,10 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { border: "0.8px solid var(--tile-stroke, rgba(255,255,255,0.08))", borderRadius: "24px", width: "100%", - height: `${heightVh}vh`, overflow: "hidden", position: "relative", }} + className="w-full h-full" > Date: Mon, 16 Mar 2026 16:04:14 +0530 Subject: [PATCH 5/5] improve: add x-direction translation functionality with speed control to BackgrounTile line render --- src/components/ui/BackgroundTile.tsx | 120 +++++++++++++++++---------- 1 file changed, 74 insertions(+), 46 deletions(-) diff --git a/src/components/ui/BackgroundTile.tsx b/src/components/ui/BackgroundTile.tsx index a42c2a7..fe497c6 100644 --- a/src/components/ui/BackgroundTile.tsx +++ b/src/components/ui/BackgroundTile.tsx @@ -2,24 +2,40 @@ import { useEffect, useRef } from "react"; -// Stable random values generated once at module level — fixes "impure function in render" warning +// Use this component in below shown way and adjust the height according to your use case. +/* +
+ +
+*/ + +// Stable random values generated once at module level const PHASES = Array.from({ length: 6 }, () => Math.random() * Math.PI * 2); const FREQS = Array.from({ length: 6 }, (_, i) => 0.3 + i * 0.13); -interface BackgroundTileProps { - /** Height of the tile in viewport height units. Defaults to 50. */ - heightVh?: number; -} +// SPEED CONFIGURATION +// Change this! 0.5 is subtle, 5.0 is incredibly fast. +const TRANSLATION_SPEED = 0.75; + +// Vertical squiggling speed +const UNDULATION_SPEED = 0.055; +// -const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { +const BackgroundTile = () => { const canvasRef = useRef(null); const rafRef = useRef(null); const lineColorRef = useRef(""); const isRunning = useRef(false); const timeRef = useRef(0); - const ampRef = useRef(0); // target amplitude - const ampSmoothed = useRef(0); // smoothed amplitude (what's actually rendered) - const stopTimer = useRef | null>(null); + const ampRef = useRef(0); + const ampSmoothed = useRef(0); + + // Forward translation trackers + const xOffsetRef = useRef(0); + const targetXSpeed = useRef(0); + const currentXSpeed = useRef(0); + + const stopTimer = useRef | null>(null); useEffect(() => { const canvas = canvasRef.current; @@ -28,7 +44,6 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { const ctx = canvas.getContext("2d"); if (!ctx) return; - // ── cache line color, refresh on theme changes ──────────────────── const updateLineColor = () => { lineColorRef.current = getComputedStyle(document.documentElement) .getPropertyValue("--line-color") @@ -45,16 +60,14 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { }); themeObserver.observe(document.documentElement, { attributes: true }); - // ── resize ──────────────────────────────────────────────────────── const setSize = () => { const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = canvas.offsetHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; - setSize(); // initial sizing before first render + setSize(); - // ── render one frame ────────────────────────────────────────────── const render = () => { const w = canvas.offsetWidth; const h = canvas.offsetHeight; @@ -63,25 +76,33 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { ctx.clearRect(0, 0, w, h); - const N = 6; - const pts = Array.from({ length: N }, (_, i) => { - const w1 = Math.sin(t * FREQS[i] + PHASES[i]) * 0.50; - const w2 = Math.sin(t * FREQS[i] * 0.43 + PHASES[i] * 1.77) * 0.30; - const w3 = Math.cos(t * FREQS[i] * 0.27 + PHASES[i] * 0.85) * 0.20; - return { - x: (i / (N - 1)) * w, + const N = 6; + const spacing = w / (N - 1); + const patternWidth = N * spacing; + + const xOffset = ((xOffsetRef.current % patternWidth) + patternWidth) % patternWidth; + + const pts = []; + for (let i = -N * 2; i <= N * 2; i++) { + const idx = ((i % N) + N) % N; + const w1 = Math.sin(t * FREQS[idx] + PHASES[idx]) * 0.50; + const w2 = Math.sin(t * FREQS[idx] * 0.43 + PHASES[idx] * 1.77) * 0.30; + const w3 = Math.cos(t * FREQS[idx] * 0.27 + PHASES[idx] * 0.85) * 0.20; + + pts.push({ + x: i * spacing + xOffset, y: h * 0.5 + (w1 + w2 + w3) * h * amp * 0.95, - }; - }); + }); + } ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); - for (let i = 0; i < N - 1; i++) { + for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[Math.max(0, i - 1)]; const p1 = pts[i]; const p2 = pts[i + 1]; - const p3 = pts[Math.min(N - 1, i + 2)]; + const p3 = pts[Math.min(pts.length - 1, i + 2)]; const k = 0.5; ctx.bezierCurveTo( p1.x + (p2.x - p0.x) * k / 3, @@ -105,37 +126,35 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { ctx.stroke(); }; - render(); // initial flat line + render(); - // ── ResizeObserver — registered after render() is defined ───────── const ro = new ResizeObserver(() => { setSize(); render(); }); ro.observe(canvas); - // ── loop — only alive while scrolling ───────────────────────────── const tick = () => { + // Lerp toward target, then decay target — order matters + currentXSpeed.current += (targetXSpeed.current - currentXSpeed.current) * 0.15; + targetXSpeed.current *= 0.85; + xOffsetRef.current += currentXSpeed.current; + + const targetAmp = ampRef.current; + if (!isRunning.current) { - // Smoothly finish lerping to the frozen target, then stop - const target = ampRef.current; - const current = ampSmoothed.current; - ampSmoothed.current += (target - current) * 0.18; + ampSmoothed.current += (targetAmp - ampSmoothed.current) * 0.18; - if (Math.abs(ampSmoothed.current - target) > 0.001) { + if (Math.abs(ampSmoothed.current - targetAmp) > 0.001 || Math.abs(currentXSpeed.current) > 0.01) { render(); rafRef.current = requestAnimationFrame(tick); } else { - ampSmoothed.current = target; // snap to exact target + ampSmoothed.current = targetAmp; render(); rafRef.current = null; } return; } - timeRef.current += 0.055; - - // Lerp smoothed amp toward target — fast rise - const target = ampRef.current; - const current = ampSmoothed.current; - ampSmoothed.current += (target - current) * 0.18; + timeRef.current += UNDULATION_SPEED; + ampSmoothed.current += (targetAmp - ampSmoothed.current) * 0.18; render(); rafRef.current = requestAnimationFrame(tick); @@ -144,19 +163,16 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { const startLoop = () => { if (!isRunning.current) { isRunning.current = true; - if (rafRef.current === null) { - rafRef.current = requestAnimationFrame(tick); - } + if (rafRef.current === null) rafRef.current = requestAnimationFrame(tick); } }; const stopLoop = () => { - // Stop animating time — amplitude stays frozen at current ampRef value isRunning.current = false; }; - // ── scroll / wheel ───────────────────────────────────────────────── let lastWheelTime = 0; + let lastScrollY = typeof window !== "undefined" ? window.scrollY : 0; const triggerScroll = (amp: number) => { ampRef.current = amp; @@ -169,11 +185,23 @@ const BackgroundTile = ({ heightVh = 50 }: BackgroundTileProps) => { lastWheelTime = Date.now(); const vel = Math.min(Math.abs(e.deltaY) / 60, 1); triggerScroll(0.18 + vel * 0.74); + + // Direct, readable speed — TRANSLATION_SPEED is 1:1 with px/frame + targetXSpeed.current = Math.sign(e.deltaY) * vel * TRANSLATION_SPEED * 8; + + if (rafRef.current === null) rafRef.current = requestAnimationFrame(tick); }; const onScroll = () => { - // Suppress if a wheel event fired within the last 100 ms — wheel already - // set a velocity-based amplitude that's more accurate than the fallback. + const currentScrollY = window.scrollY; + const deltaY = currentScrollY - lastScrollY; + lastScrollY = currentScrollY; + + if (Math.abs(deltaY) > 0) { + targetXSpeed.current = Math.sign(deltaY) * Math.min(Math.abs(deltaY) / 60, 1) * TRANSLATION_SPEED * 8; + if (rafRef.current === null) rafRef.current = requestAnimationFrame(tick); + } + if (Date.now() - lastWheelTime < 100) return; triggerScroll(0.55); };