Hi everyone ![]()
I’m trying to build a 3D particles animation that i just saw at the end of the hume.ai contact page (with Next.js + Three.js; optionally with Framer Motion or gsap for UI). My goal is a smooth particle field with sine-like motion and some interaction on hover/scroll, exactly like the one on Hume.
Honestly, I’m new to both Three.js and Next.js, so I really need help. If you’ve seen similar examples or have any tips for animation and styling, I would be glad to hear them.Thank you🌸
Here is the code I’ve written so far:
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
// Resource Tracker Class
class ResourceTracker {
constructor() {
this.resources = new Set();
}
track(resource) {
if (!resource) return resource;
if (Array.isArray(resource)) {
resource.forEach(r => this.track(r));
return resource;
}
if (resource.dispose || resource instanceof THREE.Object3D) {
this.resources.add(resource);
}
if (resource instanceof THREE.Object3D) {
this.track(resource.geometry);
this.track(resource.material);
this.track(resource.children);
} else if (resource instanceof THREE.Material) {
for (let value of Object.values(resource)) {
if (value instanceof THREE.Texture) {
this.track(value);
}
}
if (resource.uniforms) {
for (let uniform of Object.values(resource.uniforms)) {
if (uniform?.value) {
let value = uniform.value;
if (value instanceof THREE.Texture || Array.isArray(value)) {
this.track(value);
}
}
}
}
}
return resource;
}
dispose() {
for (let resource of this.resources) {
if (resource instanceof THREE.Object3D && resource.parent) {
resource.parent.remove(resource);
}
if (resource.dispose) {
resource.dispose();
}
}
this.resources.clear();
}
}
const ContactCanvas = () => {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
let animationId: number;
const resTracker = new ResourceTracker();
const track = resTracker.track.bind(resTracker);
const config = {
useWindow: true,
dark: false,
fade: true,
gap: 5.0,
scale: 15,
zDepth: 75,
xTarget: 0,
yTarget: 7.53,
y: 5.1,
z: 27
};
const scene = new THREE.Scene();
const sceneTwo = new THREE.Scene();
const sceneBackgroundColor = config.dark ? '#FFF4E8' : '#353535';
scene.background = new THREE.Color(sceneBackgroundColor);
const particleColors = config.dark ? [
new THREE.Color(0xd9110c),
new THREE.Color(0x1e0a68),
new THREE.Color(0x4caf50),
new THREE.Color(0xa036f4),
new THREE.Color(0xf44336),
new THREE.Color(0x3f51b5),
new THREE.Color(0x009688),
new THREE.Color(0x673ab7),
new THREE.Color(0xff9800),
new THREE.Color(0xe91e63),
new THREE.Color(0xffc1b4),
new THREE.Color(0x5c6bc0)
] : [
new THREE.Color(0xd9110c),
new THREE.Color(0x1e0a68),
new THREE.Color(0x254c26),
new THREE.Color(0x0065e7),
new THREE.Color(0xff8874),
new THREE.Color(0xc83f00),
new THREE.Color(0xff8801),
new THREE.Color(0x5208d2),
new THREE.Color(0xbdd4b0),
new THREE.Color(0xc2ad70),
new THREE.Color(0xd0c696),
new THREE.Color(0xe8cfae)
];
const particlesAmountX = 45;
const particlesAmountZ = config.zDepth;
const cameraLookAt = {
xTarget: config.xTarget,
yTarget: config.yTarget,
y: config.y,
z: config.z
};
const mouseMovement = { scale: 0.028, speed: 0.021 };
let mouseX = 0;
let mouseY = 0;
let count = 0;
const wrapper = config.useWindow ? window : wrapperRef.current!;
const sizes = {
width: wrapperRef.current?.offsetWidth || window.innerWidth,
height: 223
};
const camera = new THREE.PerspectiveCamera(
50,
sizes.width / sizes.height,
1,
200
);
camera.position.x = (particlesAmountX * config.gap) / 2;
camera.position.y = cameraLookAt.y;
camera.position.z = cameraLookAt.z;
scene.add(camera);
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
sceneTwo.add(ambientLight);
// Create particles
const particleCount = particlesAmountX * particlesAmountZ;
const positions = new Float32Array(particleCount * 3);
const scales = new Float32Array(particleCount);
const randomPositions = new Float32Array(particleCount);
const randomColorIndices = new Float32Array(particleCount);
const baseYPositions = new Float32Array(particleCount);
const totalWidth = particlesAmountX * config.gap;
const totalDepth = particlesAmountZ * config.gap;
let vertexIndex = 0;
let particleIndex = 0;
for (let x = 0; x < particlesAmountX; x++) {
for (let z = 0; z < particlesAmountZ; z++) {
const randomPos = Math.random();
const randomColorIndex = Math.floor(Math.random() * particleColors.length);
randomPositions[particleIndex] = randomPos;
randomColorIndices[particleIndex] = randomColorIndex;
positions[vertexIndex] =
x * config.gap + config.gap * (2 * randomPos - 1) - totalWidth / 2;
positions[vertexIndex + 1] = (Math.random() - 0.5) * 223;
baseYPositions[particleIndex] = positions[vertexIndex + 1];
positions[vertexIndex + 2] =
z * config.gap +
config.gap * (Math.random() * 2 - 1) -
totalDepth +
20 * config.gap;
scales[particleIndex] = 1 * Math.min(window.devicePixelRatio || 1, 2);
vertexIndex += 3;
particleIndex++;
}
}
const geometry = track(new THREE.BufferGeometry());
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('scale', new THREE.BufferAttribute(scales, 1));
geometry.setAttribute('randomPosition', new THREE.BufferAttribute(randomPositions, 1));
geometry.setAttribute('randomColorIndex', new THREE.BufferAttribute(randomColorIndices, 1));
geometry.setAttribute('baseY', new THREE.BufferAttribute(baseYPositions, 1));
const material = track(
new THREE.ShaderMaterial({
uniforms: {
colors: { value: particleColors },
zWidth: { value: totalDepth }
},
transparent: true,
vertexShader: `
attribute float scale;
attribute float randomColorIndex;
varying float vColorIndex;
varying vec4 pos;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = scale * (3.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
pos = gl_Position;
vColorIndex = randomColorIndex;
}
`,
fragmentShader: `
uniform vec3 colors[12];
uniform float zWidth;
varying float vColorIndex;
varying vec4 pos;
void main() {
if(length(gl_PointCoord - vec2(0.5, 0.5)) > 0.475) discard;
float zScale = pos.z / zWidth;
int colorIndex = int(vColorIndex);
vec3 color;
if(colorIndex == 0) color = colors[0];
else if(colorIndex == 1) color = colors[1];
else if(colorIndex == 2) color = colors[2];
else if(colorIndex == 3) color = colors[3];
else if(colorIndex == 4) color = colors[4];
else if(colorIndex == 5) color = colors[5];
else if(colorIndex == 6) color = colors[6];
else if(colorIndex == 7) color = colors[7];
else if(colorIndex == 8) color = colors[8];
else if(colorIndex == 9) color = colors[9];
else if(colorIndex == 10) color = colors[10];
else if(colorIndex == 11) color = colors[11];
gl_FragColor = vec4(color, zScale * 6.0);
}
`
})
);
const particles = track(new THREE.Points(geometry, material));
scene.add(particles);
const renderer = new THREE.WebGLRenderer({
canvas: canvasRef.current!,
antialias: !(window.devicePixelRatio > 1)
});
renderer.autoClear = false;
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const handlePointerMove = (event: PointerEvent) => {
if (event.isPrimary === false) return;
mouseX = (event.clientX / window.innerWidth) * 2 - 1;
mouseY = (event.clientY / window.innerHeight) * 2 - 1;
};
const handleResize = () => {
sizes.width = wrapperRef.current?.offsetWidth || window.innerWidth;
sizes.height = 223;
camera.aspect = sizes.width / sizes.height;
camera.updateProjectionMatrix();
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
};
const updateParticles = () => {
const positionsArray = particles.geometry.attributes.position.array as Float32Array;
const scalesArray = particles.geometry.attributes.scale.array as Float32Array;
const randomPosArray = particles.geometry.attributes.randomPosition.array as Float32Array;
const baseYArray = particles.geometry.attributes.baseY.array as Float32Array;
const particleScale = config.scale * Math.min(window.devicePixelRatio || 1, 2);
let vertexIndex = 0;
let particleIndex = 0;
for (let x = 0; x < particlesAmountX; x++) {
for (let z = 0; z < particlesAmountZ; z++) {
const random = randomPosArray[particleIndex];
positionsArray[vertexIndex + 1] =
baseYArray[particleIndex] +
(0.6 *
Math.sin((random * particlesAmountX + count) * 0.3) *
random +
0.6 *
Math.sin((random * particlesAmountZ + count) * 0.5) *
random);
scalesArray[particleIndex] =
(Math.sin((x + count) * 0.3) + 2) *
particleScale *
(random / 2 + 0.5) +
(Math.sin((z + count) * 0.5) + 2.5) *
particleScale *
(random / 2 + 0.5);
vertexIndex += 3;
particleIndex++;
}
}
particles.geometry.attributes.position.needsUpdate = true;
particles.geometry.attributes.scale.needsUpdate = true;
};
const updateCamera = () => {
const targetX =
mouseX * (particlesAmountX * config.gap * mouseMovement.scale);
const targetY = -mouseY * (50 * mouseMovement.scale) + cameraLookAt.y;
camera.position.x += (targetX - camera.position.x) * mouseMovement.speed;
camera.position.y += (targetY - camera.position.y) * mouseMovement.speed;
camera.lookAt(cameraLookAt.xTarget, cameraLookAt.yTarget, 0);
};
const animate = () => {
updateParticles();
updateCamera();
renderer.clear();
renderer.render(scene, camera);
renderer.clearDepth();
renderer.render(sceneTwo, camera);
count += 0.04;
animationId = requestAnimationFrame(animate);
};
wrapper.addEventListener('pointermove', handlePointerMove);
window.addEventListener('resize', handleResize);
animate();
return () => {
if (animationId) {
cancelAnimationFrame(animationId);
}
wrapper.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('resize', handleResize);
resTracker.dispose();
renderer.dispose();
};
}, []);
return (
<div
className="bottom-0 absolute w-full h-[223px] overflow-hidden"
ref={wrapperRef}
>
<canvas
className="top-0 bottom-0 left-0 absolute w-full h-[223px]"
data-engine="three.js r167"
ref={canvasRef}
style={{ width: '100%', height: '223px' }}
/>
</div>
);
};
export default ContactCanvas;
