Dot Grid Background

Hover
Canvas
Three.js
JS
Last Updated: Mar 3, 2026
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
	document.querySelectorAll(".hover-8_wrap").forEach((component) => {
		if (component.hasAttribute("data-hover-8")) return;
		component.setAttribute("data-hover-8", "");
		const canvas = component.querySelector("canvas");

		const DOT_SPACING = 10;
		const DOT_RADIUS = 1.4;
		const HOVER_RADIUS = 200;
		const HOVER_SCALE = 6;
		const MIN_GAP = 1.0;
		const LERP_SPEED_UP = 0.12;
		const LERP_SPEED_DOWN = 0.025;
		const MOUSE_LERP = 0.35;
		const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

		let W = component.clientWidth;
		let H = component.clientHeight;

		const computedStyle = getComputedStyle(canvas);

		function parseCSSColor(str) {
			const ctx = document.createElement('canvas').getContext('2d');
			ctx.fillStyle = str;
			ctx.fillRect(0, 0, 1, 1);
			const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
			return new THREE.Color(r / 255, g / 255, b / 255);
		}

		const BASE_COLOR = parseCSSColor(computedStyle.color || '#8090b8');

		const scene = new THREE.Scene();
		const camera = new THREE.OrthographicCamera(-W / 2, W / 2, H / 2, -H / 2, 1, 10);
		camera.position.z = 5;

		const renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true });
		renderer.setClearColor(0x000000, 0);
		renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
		renderer.setSize(W, H);

		const geo = new THREE.CircleGeometry(DOT_RADIUS, 32);
		const mat = new THREE.MeshBasicMaterial({ color: 0xffffff });
		const dummy = new THREE.Object3D();

		let cols, rows, count, mesh, basePositions, currentScales, currentOffsets;

		function buildGrid() {
			W = component.clientWidth;
			H = component.clientHeight;

			camera.left = -W / 2;
			camera.right = W / 2;
			camera.top = H / 2;
			camera.bottom = -H / 2;
			camera.updateProjectionMatrix();
			renderer.setSize(W, H);

			if (mesh) scene.remove(mesh);

			cols = Math.ceil(W / DOT_SPACING) + 2;
			rows = Math.ceil(H / DOT_SPACING) + 2;
			count = cols * rows;

			mesh = new THREE.InstancedMesh(geo, mat, count);
			scene.add(mesh);

			const colors = new Float32Array(count * 3);
			basePositions = new Float32Array(count * 2);
			currentScales = new Float32Array(count).fill(1);
			currentOffsets = new Float32Array(count * 2).fill(0);

			let idx = 0;
			for (let r = 0; r < rows; r++) {
				for (let c = 0; c < cols; c++) {
					const x = c * DOT_SPACING - W / 2;
					const y = H / 2 - r * DOT_SPACING;
					basePositions[idx * 2] = x;
					basePositions[idx * 2 + 1] = y;
					dummy.position.set(x, y, 0);
					dummy.scale.set(1, 1, 1);
					dummy.updateMatrix();
					mesh.setMatrixAt(idx, dummy.matrix);
					colors[idx * 3] = BASE_COLOR.r;
					colors[idx * 3 + 1] = BASE_COLOR.g;
					colors[idx * 3 + 2] = BASE_COLOR.b;
					idx++;
				}
			}

			geo.setAttribute('color', new THREE.InstancedBufferAttribute(colors, 3));
			mat.vertexColors = false;
			mesh.instanceMatrix.needsUpdate = true;
			mesh.instanceColor = new THREE.InstancedBufferAttribute(colors, 3);
		}

		buildGrid();

		const mouse = { x: -9999, y: -9999 };
		const mouseSmooth = { x: -9999, y: -9999 };
		const lastClient = { x: -9999, y: -9999, active: false };

		function updatePointer(clientX, clientY) {
			const rect = component.getBoundingClientRect();
			if (
				clientX >= rect.left &&
				clientX <= rect.right &&
				clientY >= rect.top &&
				clientY <= rect.bottom
			) {
				lastClient.x = clientX;
				lastClient.y = clientY;
				lastClient.active = true;
				mouse.x = clientX - rect.left - W / 2;
				mouse.y = H / 2 - (clientY - rect.top);
			} else {
				mouse.x = -9999;
				mouse.y = -9999;
				lastClient.active = false;
			}
		}

		function recalcFromLastClient() {
			if (!lastClient.active) return;
			const rect = component.getBoundingClientRect();
			if (
				lastClient.x >= rect.left &&
				lastClient.x <= rect.right &&
				lastClient.y >= rect.top &&
				lastClient.y <= rect.bottom
			) {
				mouse.x = lastClient.x - rect.left - W / 2;
				mouse.y = H / 2 - (lastClient.y - rect.top);
			} else {
				mouse.x = -9999;
				mouse.y = -9999;
				lastClient.active = false;
			}
		}

		document.addEventListener('mousemove', (e) => updatePointer(e.clientX, e.clientY));

		if (window.lenis) {
			window.lenis.on('scroll', recalcFromLastClient);
		} else {
			document.addEventListener('scroll', recalcFromLastClient, { passive: true });
		}

		document.addEventListener('touchmove', (e) => {
			const touch = e.touches[0];
			if (touch) updatePointer(touch.clientX, touch.clientY);
		}, { passive: true });

		document.addEventListener('touchstart', (e) => {
			const touch = e.touches[0];
			if (touch) updatePointer(touch.clientX, touch.clientY);
		}, { passive: true });

		document.addEventListener('touchend', () => {
			mouse.x = -9999;
			mouse.y = -9999;
			lastClient.active = false;
		}, { passive: true });

		function animate() {
			requestAnimationFrame(animate);

			if (prefersReducedMotion) {
				renderer.render(scene, camera);
				return;
			}

			if (mouse.x < -9000) {
				mouseSmooth.x = mouse.x;
				mouseSmooth.y = mouse.y;
			} else {
				if (mouseSmooth.x < -9000) {
					mouseSmooth.x = mouse.x;
					mouseSmooth.y = mouse.y;
				} else {
					mouseSmooth.x += (mouse.x - mouseSmooth.x) * MOUSE_LERP;
					mouseSmooth.y += (mouse.y - mouseSmooth.y) * MOUSE_LERP;
				}
			}

			for (let i = 0; i < count; i++) {
				const bx = basePositions[i * 2];
				const by = basePositions[i * 2 + 1];
				let targetScale = 1;

				const dx = bx - mouseSmooth.x;
				const dy = by - mouseSmooth.y;
				const dist = Math.sqrt(dx * dx + dy * dy);
				if (dist < HOVER_RADIUS) {
					const t = 1 - dist / HOVER_RADIUS;
					targetScale = 1 + (HOVER_SCALE - 1) * t * t * t;
				}

				const lerpSpeed = targetScale > currentScales[i] ? LERP_SPEED_UP : LERP_SPEED_DOWN;
				currentScales[i] += (targetScale - currentScales[i]) * lerpSpeed;
			}

			for (let i = 0; i < count; i++) {
				let targetOffsetX = 0;
				let targetOffsetY = 0;
				const bx = basePositions[i * 2];
				const by = basePositions[i * 2 + 1];
				const ri = DOT_RADIUS * currentScales[i];

				const col = i % cols;
				const row = (i - col) / cols;
				for (let dr = -2; dr <= 2; dr++) {
					for (let dc = -2; dc <= 2; dc++) {
						if (dr === 0 && dc === 0) continue;
						const nr = row + dr;
						const nc = col + dc;
						if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
						const j = nr * cols + nc;
						const rj = DOT_RADIUS * currentScales[j];
						const nbx = basePositions[j * 2];
						const nby = basePositions[j * 2 + 1];
						const ddx = bx - nbx;
						const ddy = by - nby;
						const dist = Math.sqrt(ddx * ddx + ddy * ddy);
						const minDist = ri + rj + MIN_GAP;
						if (dist < minDist && dist > 0.01) {
							const overlap = minDist - dist;
							targetOffsetX += (ddx / dist) * overlap * 0.5;
							targetOffsetY += (ddy / dist) * overlap * 0.5;
						}
					}
				}

				const offsetLerp = (targetOffsetX !== 0 || targetOffsetY !== 0) ? 0.15 : LERP_SPEED_DOWN;
				currentOffsets[i * 2] += (targetOffsetX - currentOffsets[i * 2]) * offsetLerp;
				currentOffsets[i * 2 + 1] += (targetOffsetY - currentOffsets[i * 2 + 1]) * offsetLerp;

				const s = currentScales[i];
				dummy.position.set(bx + currentOffsets[i * 2], by + currentOffsets[i * 2 + 1], 0);
				dummy.scale.set(s, s, 1);
				dummy.updateMatrix();
				mesh.setMatrixAt(i, dummy.matrix);

				mesh.instanceColor.array[i * 3] = BASE_COLOR.r;
				mesh.instanceColor.array[i * 3 + 1] = BASE_COLOR.g;
				mesh.instanceColor.array[i * 3 + 2] = BASE_COLOR.b;
			}

			mesh.instanceMatrix.needsUpdate = true;
			mesh.instanceColor.needsUpdate = true;
			renderer.render(scene, camera);
		}
		animate();

		let resizeTimer = null;
		let lastW = W;
		let lastH = H;

		const ro = new ResizeObserver(() => {
			const newW = component.clientWidth;
			const newH = component.clientHeight;
			const widthChanged = newW !== lastW;
			const heightChanged = Math.abs(newH - lastH) > 100;
			if (!widthChanged && !heightChanged) return;
			clearTimeout(resizeTimer);
			resizeTimer = setTimeout(() => {
				lastW = component.clientWidth;
				lastH = component.clientHeight;
				buildGrid();
			}, 150);
		});
		ro.observe(component);
	});
});
</script>