Orb Field
A high-performance 3D particle field with interactive physics and cursor tracking.
Overview
A cinematic 3D particle simulation built with Three.js. Features hundreds of interactive orbs with realistic physics, collision detection, and cursor-following leader orb for immersive backgrounds.
Features
- GPU-accelerated instanced rendering
- Real-time physics with gravity and friction
- Cursor-interactive leader orb
- Collision detection between orbs
- Customizable colors and material properties
Preview
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/orb-field.jsonNote: The CLI does not set up the global
ThemeProvider. Please see the Configuration guide to set up dark mode support.
Method 2: Manual
-
Install Dependencies
npm install three @types/three gsap @gsap/react framer-motion next-themes -
Setup Theme Provider
This component uses
next-themes. Ensure your app is wrapped in aThemeProvideras described in the Global Installation guide. -
Copy the Source Code
Copy the code below into
components/zenblocks/orb-field.tsx.Click to expand source
"use client"; import React, { useRef, useEffect, useState } from "react"; import { Clock, PerspectiveCamera, Scene, WebGLRenderer, WebGLRendererParameters, SRGBColorSpace, MathUtils, Vector2, Vector3, Quaternion, MeshPhysicalMaterial, Color, Object3D, InstancedMesh, PMREMGenerator, AmbientLight, PointLight, ACESFilmicToneMapping, Raycaster, Plane, Matrix4, } from "three"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js"; import { gsap, Observer } from "gsap/all"; import { motion, useInView, MotionProps } from "framer-motion"; import { useTheme } from "next-themes"; import { JSX } from "react"; gsap.registerPlugin(Observer); /* ========================================================================== COMPONENT: ORB FIELD (Formerly Ballpit) ========================================================================== */ export interface OrbFieldProps { className?: string; colors?: number[]; count?: number; gravity?: number; friction?: number; wallBounce?: number; followCursor?: boolean; ambientColor?: number; ambientIntensity?: number; lightIntensity?: number; minSize?: number; maxSize?: number; maxVelocity?: number; size0?: number; // Size of the lead orb materialParams?: { metalness?: number; roughness?: number; clearcoat?: number; clearcoatRoughness?: number; transmission?: number; ior?: number; }; } interface OrbFieldConfig { canvas?: HTMLCanvasElement; id?: string; rendererOptions?: Partial<WebGLRendererParameters>; size?: "parent" | { width: number; height: number }; } interface SizeData { width: number; height: number; wWidth: number; wHeight: number; ratio: number; pixelRatio: number; } interface PhysicsConfig { count: number; maxX: number; maxY: number; maxZ: number; maxSize: number; minSize: number; size0: number; gravity: number; friction: number; wallBounce: number; maxVelocity: number; controlOrb0?: boolean; followCursor?: boolean; colors: number[]; ambientColor: number; ambientIntensity: number; lightIntensity: number; materialParams: { metalness?: number; roughness?: number; clearcoat?: number; clearcoatRoughness?: number; transmission?: number; ior?: number; }; } class OrbFieldScene { #config: OrbFieldConfig; #postprocessing: any; #resizeObserver?: ResizeObserver; #intersectionObserver?: IntersectionObserver; #resizeTimer?: number; #animationFrameId: number = 0; #clock: Clock = new Clock(); #animationState = { elapsed: 0, delta: 0 }; #isAnimating: boolean = false; #isVisible: boolean = false; canvas!: HTMLCanvasElement; camera!: PerspectiveCamera; cameraMinAspect?: number; cameraMaxAspect?: number; cameraFov!: number; maxPixelRatio?: number; minPixelRatio?: number; scene!: Scene; renderer!: WebGLRenderer; size: SizeData = { width: 0, height: 0, wWidth: 0, wHeight: 0, ratio: 0, pixelRatio: 0, }; render: () => void = this.#render.bind(this); onBeforeRender: (state: { elapsed: number; delta: number }) => void = () => { }; onAfterRender: (state: { elapsed: number; delta: number }) => void = () => { }; onAfterResize: (size: SizeData) => void = () => { }; isDisposed: boolean = false; constructor(config: OrbFieldConfig) { this.#config = { ...config }; this.#initCamera(); this.#initScene(); this.#initRenderer(); this.resize(); this.#initObservers(); } #initCamera() { this.camera = new PerspectiveCamera(); this.cameraFov = this.camera.fov; } #initScene() { this.scene = new Scene(); } #initRenderer() { if (this.#config.canvas) { this.canvas = this.#config.canvas; } else if (this.#config.id) { const elem = document.getElementById(this.#config.id); if (elem instanceof HTMLCanvasElement) { this.canvas = elem; } else { console.error("Three: Missing canvas or id parameter"); } } else { console.error("Three: Missing canvas or id parameter"); } this.canvas!.style.display = "block"; // Ensure alpha is true for transparent background const rendererOptions: WebGLRendererParameters = { canvas: this.canvas, powerPreference: "high-performance", alpha: true, ...(this.#config.rendererOptions ?? {}), }; this.renderer = new WebGLRenderer(rendererOptions); this.renderer.outputColorSpace = SRGBColorSpace; this.renderer.setClearColor(0x000000, 0); // Explicitly set clear color to transparent } #initObservers() { if (!(this.#config.size instanceof Object)) { window.addEventListener("resize", this.#onResize.bind(this)); if (this.#config.size === "parent" && this.canvas.parentNode) { this.#resizeObserver = new ResizeObserver(this.#onResize.bind(this)); this.#resizeObserver.observe(this.canvas.parentNode as Element); } } this.#intersectionObserver = new IntersectionObserver( this.#onIntersection.bind(this), { root: null, rootMargin: "0px", threshold: 0, } ); this.#intersectionObserver.observe(this.canvas); document.addEventListener( "visibilitychange", this.#onVisibilityChange.bind(this) ); } #onResize() { if (this.#resizeTimer) clearTimeout(this.#resizeTimer); this.#resizeTimer = window.setTimeout(this.resize.bind(this), 100); } resize() { let w: number, h: number; if (this.#config.size instanceof Object) { w = this.#config.size.width; h = this.#config.size.height; } else if (this.#config.size === "parent" && this.canvas.parentNode) { w = (this.canvas.parentNode as HTMLElement).offsetWidth; h = (this.canvas.parentNode as HTMLElement).offsetHeight; } else { w = window.innerWidth; h = window.innerHeight; } this.size.width = w; this.size.height = h; this.size.ratio = w / h; this.#updateCamera(); this.#updateRenderer(); this.onAfterResize(this.size); } #updateCamera() { this.camera.aspect = this.size.width / this.size.height; if (this.camera.isPerspectiveCamera && this.cameraFov) { if (this.cameraMinAspect && this.camera.aspect < this.cameraMinAspect) { this.#adjustFov(this.cameraMinAspect); } else if ( this.cameraMaxAspect && this.camera.aspect > this.cameraMaxAspect ) { this.#adjustFov(this.cameraMaxAspect); } else { this.camera.fov = this.cameraFov; } } this.camera.updateProjectionMatrix(); this.updateWorldSize(); } #adjustFov(aspect: number) { const tanFov = Math.tan(MathUtils.degToRad(this.cameraFov / 2)); const newTan = tanFov / (this.camera.aspect / aspect); this.camera.fov = 2 * MathUtils.radToDeg(Math.atan(newTan)); } updateWorldSize() { if (this.camera.isPerspectiveCamera) { const fovRad = (this.camera.fov * Math.PI) / 180; this.size.wHeight = 2 * Math.tan(fovRad / 2) * this.camera.position.length(); this.size.wWidth = this.size.wHeight * this.camera.aspect; } else if ((this.camera as any).isOrthographicCamera) { const cam = this.camera as any; this.size.wHeight = cam.top - cam.bottom; this.size.wWidth = cam.right - cam.left; } } #updateRenderer() { this.renderer.setSize(this.size.width, this.size.height); this.#postprocessing?.setSize(this.size.width, this.size.height); let pr = window.devicePixelRatio; if (this.maxPixelRatio && pr > this.maxPixelRatio) { pr = this.maxPixelRatio; } else if (this.minPixelRatio && pr < this.minPixelRatio) { pr = this.minPixelRatio; } this.renderer.setPixelRatio(pr); this.size.pixelRatio = pr; } get postprocessing() { return this.#postprocessing; } set postprocessing(value: any) { this.#postprocessing = value; this.render = value.render.bind(value); } #onIntersection(entries: IntersectionObserverEntry[]) { this.#isAnimating = entries[0].isIntersecting; this.#isAnimating ? this.#startAnimation() : this.#stopAnimation(); } #onVisibilityChange() { if (this.#isAnimating) { document.hidden ? this.#stopAnimation() : this.#startAnimation(); } } #startAnimation() { if (this.#isVisible) return; const animateFrame = () => { this.#animationFrameId = requestAnimationFrame(animateFrame); this.#animationState.delta = this.#clock.getDelta(); this.#animationState.elapsed += this.#animationState.delta; this.onBeforeRender(this.#animationState); this.render(); this.onAfterRender(this.#animationState); }; this.#isVisible = true; this.#clock.start(); animateFrame(); } #stopAnimation() { if (this.#isVisible) { cancelAnimationFrame(this.#animationFrameId); this.#isVisible = false; this.#clock.stop(); } } #render() { this.renderer.render(this.scene, this.camera); } clear() { this.scene.traverse((obj) => { if ( (obj as any).isMesh && typeof (obj as any).material === "object" && (obj as any).material !== null ) { Object.keys((obj as any).material).forEach((key) => { const matProp = (obj as any).material[key]; if ( matProp && typeof matProp === "object" && typeof matProp.dispose === "function" ) { matProp.dispose(); } }); (obj as any).material.dispose(); (obj as any).geometry.dispose(); } }); this.scene.clear(); } dispose() { this.#onResizeCleanup(); this.#stopAnimation(); this.clear(); this.#postprocessing?.dispose(); this.renderer.dispose(); this.isDisposed = true; } #onResizeCleanup() { window.removeEventListener("resize", this.#onResize.bind(this)); this.#resizeObserver?.disconnect(); this.#intersectionObserver?.disconnect(); document.removeEventListener( "visibilitychange", this.#onVisibilityChange.bind(this) ); } } class OrbFieldPhysics { config: PhysicsConfig; positionData: Float32Array; velocityData: Float32Array; sizeData: Float32Array; quaternionData: Float32Array; angularVelocityData: Float32Array; center: Vector3 = new Vector3(); constructor(config: PhysicsConfig) { this.config = config; this.positionData = new Float32Array(3 * config.count).fill(0); this.velocityData = new Float32Array(3 * config.count).fill(0); this.sizeData = new Float32Array(config.count).fill(1); this.quaternionData = new Float32Array(4 * config.count).fill(0); this.angularVelocityData = new Float32Array(3 * config.count).fill(0); this.center = new Vector3(); this.#initializePositions(); this.setSizes(); } #initializePositions() { const { config, positionData, quaternionData, angularVelocityData } = this; this.center.toArray(positionData, 0); // Init rotation for index 0 const q0 = new Quaternion(); q0.toArray(quaternionData, 0); for (let i = 1; i < config.count; i++) { const idx = 3 * i; const qIdx = 4 * i; // Position - Use wider spread for full screen start positionData[idx] = MathUtils.randFloatSpread(2.2 * config.maxX); positionData[idx + 1] = MathUtils.randFloatSpread(2.2 * config.maxY); positionData[idx + 2] = MathUtils.randFloatSpread(2.2 * config.maxZ); // Random Rotation const q = new Quaternion().setFromEuler( new Object3D().rotation.set( Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI ) as any ); q.toArray(quaternionData, qIdx); // Random Angular Velocity angularVelocityData[idx] = MathUtils.randFloatSpread(0.2); angularVelocityData[idx + 1] = MathUtils.randFloatSpread(0.2); angularVelocityData[idx + 2] = MathUtils.randFloatSpread(0.2); } } setSizes() { const { config, sizeData } = this; sizeData[0] = config.size0; for (let i = 1; i < config.count; i++) { sizeData[i] = MathUtils.randFloat(config.minSize, config.maxSize); } } update(deltaInfo: { delta: number }) { const { config, center, positionData, sizeData, velocityData, quaternionData, angularVelocityData, } = this; let startIdx = 0; if (config.controlOrb0) { startIdx = 1; const firstVec = new Vector3().fromArray(positionData, 0); firstVec.lerp(center, 0.1).toArray(positionData, 0); new Vector3(0, 0, 0).toArray(velocityData, 0); new Quaternion().toArray(quaternionData, 0); new Vector3(0, 0, 0).toArray(angularVelocityData, 0); } const damping = 0.98; for (let idx = startIdx; idx < config.count; idx++) { const base = 3 * idx; const qBase = 4 * idx; // Linear Movement const pos = new Vector3().fromArray(positionData, base); const vel = new Vector3().fromArray(velocityData, base); vel.y -= deltaInfo.delta * config.gravity * sizeData[idx]; vel.multiplyScalar(config.friction); vel.clampLength(0, config.maxVelocity); pos.add(vel); pos.toArray(positionData, base); vel.toArray(velocityData, base); // Angular Movement const q = new Quaternion().fromArray(quaternionData, qBase); const angVel = new Vector3().fromArray(angularVelocityData, base); const rotStep = new Quaternion().setFromEuler( new Object3D().rotation.set(angVel.x, angVel.y, angVel.z) as any ); q.multiply(rotStep); q.normalize(); q.toArray(quaternionData, qBase); angVel.multiplyScalar(damping); angVel.toArray(angularVelocityData, base); } // Collisions for (let idx = startIdx; idx < config.count; idx++) { const base = 3 * idx; const pos = new Vector3().fromArray(positionData, base); const vel = new Vector3().fromArray(velocityData, base); const radius = sizeData[idx] * 0.75; for (let jdx = idx + 1; jdx < config.count; jdx++) { const otherBase = 3 * jdx; const otherPos = new Vector3().fromArray(positionData, otherBase); const otherVel = new Vector3().fromArray(velocityData, otherBase); const diff = new Vector3().copy(otherPos).sub(pos); const dist = diff.length(); const sumRadius = radius + sizeData[jdx] * 0.75; if (dist < sumRadius) { const overlap = sumRadius - dist; const correction = diff.normalize().multiplyScalar(0.5 * overlap); const velCorrection = correction .clone() .multiplyScalar(Math.max(vel.length(), 1)); pos.sub(correction); vel.sub(velCorrection); otherPos.add(correction); otherVel.add( correction.clone().multiplyScalar(Math.max(otherVel.length(), 1)) ); const impactStrength = vel.length() + otherVel.length(); if (impactStrength > 0.1) { const tumble = Math.min(impactStrength * 0.05, 0.2); this.applyTorque(idx, tumble); this.applyTorque(jdx, tumble); } pos.toArray(positionData, base); vel.toArray(velocityData, base); otherPos.toArray(positionData, otherBase); otherVel.toArray(velocityData, otherBase); } } if (config.controlOrb0) { const diff = new Vector3() .copy(new Vector3().fromArray(positionData, 0)) .sub(pos); const d = diff.length(); const sumRadius0 = radius + sizeData[0] * 0.75; if (d < sumRadius0) { const correction = diff.normalize().multiplyScalar(sumRadius0 - d); const velCorrection = correction .clone() .multiplyScalar(Math.max(vel.length(), 2)); pos.sub(correction); vel.sub(velCorrection); this.applyTorque(idx, 0.1); } } // Walls let collided = false; if (Math.abs(pos.x) + radius > config.maxX) { pos.x = Math.sign(pos.x) * (config.maxX - radius); vel.x = -vel.x * config.wallBounce; collided = true; } if (config.gravity === 0) { if (Math.abs(pos.y) + radius > config.maxY) { pos.y = Math.sign(pos.y) * (config.maxY - radius); vel.y = -vel.y * config.wallBounce; collided = true; } } else if (pos.y - radius < -config.maxY) { pos.y = -config.maxY + radius; vel.y = -vel.y * config.wallBounce; collided = true; } const maxBoundary = Math.max(config.maxZ, config.maxSize); if (Math.abs(pos.z) + radius > maxBoundary) { pos.z = Math.sign(pos.z) * (config.maxZ - radius); vel.z = -vel.z * config.wallBounce; collided = true; } if (collided) { this.applyTorque(idx, vel.length() * 0.02); } pos.toArray(positionData, base); vel.toArray(velocityData, base); } } applyTorque(idx: number, magnitude: number) { const base = 3 * idx; this.angularVelocityData[base] += MathUtils.randFloatSpread(magnitude); this.angularVelocityData[base + 1] += MathUtils.randFloatSpread(magnitude); this.angularVelocityData[base + 2] += MathUtils.randFloatSpread(magnitude); } } class OrbFieldMeshes extends InstancedMesh { config: PhysicsConfig; physics: OrbFieldPhysics; ambientLight: AmbientLight | undefined; light: PointLight | undefined; constructor(renderer: WebGLRenderer, params: Partial<PhysicsConfig> = {}) { const config = { ...DefaultOrbFieldConfig, ...params }; const roomEnv = new RoomEnvironment(); const pmrem = new PMREMGenerator(renderer); const envTexture = pmrem.fromScene(roomEnv).texture; // PREMIUM: RoundedBoxGeometry for bevels const geometry = new RoundedBoxGeometry(1, 1, 1, 2, 0.1); // PREMIUM: Standard Physical Material without custom shader hacking const material = new MeshPhysicalMaterial({ envMap: envTexture, metalness: 0.5, // Increased for better reflection mapping roughness: 0.4, // Increased roughness for matte finish clearcoat: 0.8, // Reduced clearcoat clearcoatRoughness: 0.2, transmission: 0, flatShading: false, ...config.materialParams, }); const rotation = (material as any).envMapRotation; if (rotation) { rotation.x = -Math.PI / 2; } super(geometry, material, config.count); this.config = config; this.physics = new OrbFieldPhysics(config); this.#setupLights(); this.setColors(config.colors); (this as any).instanceMatrix.setUsage(35048); // Dynamic draw } #setupLights() { this.ambientLight = new AmbientLight( this.config.ambientColor, this.config.ambientIntensity ); (this as any).add(this.ambientLight); // Tuned lighting this.light = new PointLight( this.config.colors[0], this.config.lightIntensity * 0.5 ); (this as any).add(this.light); } setColors(colors: number[]) { if (Array.isArray(colors) && colors.length > 1) { const colorUtils = (function (colorsArr: number[]) { let baseColors: number[] = colorsArr; let colorObjects: Color[] = []; baseColors.forEach((col) => { colorObjects.push(new Color(col)); }); return { setColors: (cols: number[]) => { baseColors = cols; colorObjects = []; baseColors.forEach((col) => { colorObjects.push(new Color(col)); }); }, getColorAt: (ratio: number, out: Color = new Color()) => { const clamped = Math.max(0, Math.min(1, ratio)); const scaled = clamped * (baseColors.length - 1); const idx = Math.floor(scaled); const start = colorObjects[idx]; if (idx >= baseColors.length - 1) return start.clone(); const alpha = scaled - idx; const end = colorObjects[idx + 1]; out.r = start.r + alpha * (end.r - start.r); out.g = start.g + alpha * (end.g - start.g); out.b = start.b + alpha * (end.b - start.b); return out; }, }; })(colors); for (let idx = 0; idx < (this as any).count; idx++) { (this as any).setColorAt( idx, colorUtils.getColorAt(idx / (this as any).count) ); if (idx === 0) { this.light!.color.copy( colorUtils.getColorAt(idx / (this as any).count) ); } } if ((this as any).instanceColor) { (this as any).instanceColor.needsUpdate = true; } } } update(deltaInfo: { delta: number }) { this.physics.update(deltaInfo); for (let idx = 0; idx < (this as any).count; idx++) { // Compose Matrix manually from Pos, Rot, Scale const pos = new Vector3().fromArray(this.physics.positionData, 3 * idx); const rot = new Quaternion().fromArray( this.physics.quaternionData, 4 * idx ); const scale = new Vector3().setScalar(this.physics.sizeData[idx]); if (idx === 0 && this.config.followCursor === false) { scale.setScalar(0); } M.compose(pos, rot, scale); (this as any).setMatrixAt(idx, M); if (idx === 0) this.light!.position.copy(pos); } (this as any).instanceMatrix.needsUpdate = true; } } const DefaultOrbFieldConfig: PhysicsConfig = { count: 60, colors: [0x18181b, 0x71717a, 0xd4d4d8, 0xffffff], // Default to light theme colors ambientColor: 0xffffff, ambientIntensity: 0.5, lightIntensity: 150, materialParams: { metalness: 0.6, roughness: 0.5, clearcoat: 0.1, clearcoatRoughness: 0.2, }, minSize: 0.5, maxSize: 1, size0: 1, gravity: 0.5, friction: 0.99, wallBounce: 0.8, maxVelocity: 0.15, maxX: 5, maxY: 5, maxZ: 2, controlOrb0: false, followCursor: true, }; const M = new Matrix4(); let globalPointerActive = false; const pointerPosition = new Vector2(); interface PointerData { position: Vector2; nPosition: Vector2; hover: boolean; touching: boolean; onEnter: (data: PointerData) => void; onMove: (data: PointerData) => void; onClick: (data: PointerData) => void; onLeave: (data: PointerData) => void; dispose?: () => void; } const pointerMap = new Map<HTMLElement, PointerData>(); function createPointerData( options: Partial<PointerData> & { domElement: HTMLElement } ): PointerData { const defaultData: PointerData = { position: new Vector2(), nPosition: new Vector2(), hover: false, touching: false, onEnter: () => { }, onMove: () => { }, onClick: () => { }, onLeave: () => { }, ...options, }; if (!pointerMap.has(options.domElement)) { pointerMap.set(options.domElement, defaultData); if (!globalPointerActive) { document.body.addEventListener( "pointermove", onPointerMove as EventListener ); document.body.addEventListener( "pointerleave", onPointerLeave as EventListener ); document.body.addEventListener("click", onPointerClick as EventListener); document.body.addEventListener( "touchstart", onTouchStart as EventListener, { passive: true, } ); document.body.addEventListener( "touchmove", onTouchMove as EventListener, { passive: true, } ); document.body.addEventListener("touchend", onTouchEnd as EventListener, { passive: true, }); document.body.addEventListener( "touchcancel", onTouchEnd as EventListener, { passive: true, } ); globalPointerActive = true; } } defaultData.dispose = () => { pointerMap.delete(options.domElement); if (pointerMap.size === 0) { document.body.removeEventListener( "pointermove", onPointerMove as EventListener ); document.body.removeEventListener( "pointerleave", onPointerLeave as EventListener ); document.body.removeEventListener( "click", onPointerClick as EventListener ); document.body.removeEventListener( "touchstart", onTouchStart as EventListener ); document.body.removeEventListener( "touchmove", onTouchMove as EventListener ); document.body.removeEventListener( "touchend", onTouchEnd as EventListener ); document.body.removeEventListener( "touchcancel", onTouchEnd as EventListener ); globalPointerActive = false; } }; return defaultData; } function onPointerMove(e: PointerEvent) { pointerPosition.set(e.clientX, e.clientY); processPointerInteraction(); } function processPointerInteraction() { for (const [elem, data] of pointerMap) { const rect = elem.getBoundingClientRect(); if (isInside(rect)) { updatePointerData(data, rect); if (!data.hover) { data.hover = true; data.onEnter(data); } data.onMove(data); } else if (data.hover && !data.touching) { data.hover = false; data.onLeave(data); } } } function onTouchStart(e: TouchEvent) { if (e.touches.length > 0) { pointerPosition.set(e.touches[0].clientX, e.touches[0].clientY); for (const [elem, data] of pointerMap) { const rect = elem.getBoundingClientRect(); if (isInside(rect)) { data.touching = true; updatePointerData(data, rect); if (!data.hover) { data.hover = true; data.onEnter(data); } data.onMove(data); } } } } function onTouchMove(e: TouchEvent) { if (e.touches.length > 0) { pointerPosition.set(e.touches[0].clientX, e.touches[0].clientY); for (const [elem, data] of pointerMap) { const rect = elem.getBoundingClientRect(); updatePointerData(data, rect); if (isInside(rect)) { if (!data.hover) { data.hover = true; data.touching = true; data.onEnter(data); } data.onMove(data); } else if (data.hover && data.touching) { data.onMove(data); } } } } function onTouchEnd() { for (const [, data] of pointerMap) { if (data.touching) { data.touching = false; if (data.hover) { data.hover = false; data.onLeave(data); } } } } function onPointerClick(e: PointerEvent) { pointerPosition.set(e.clientX, e.clientY); for (const [elem, data] of pointerMap) { const rect = elem.getBoundingClientRect(); updatePointerData(data, rect); if (isInside(rect)) data.onClick(data); } } function onPointerLeave() { for (const data of pointerMap.values()) { if (data.hover) { data.hover = false; data.onLeave(data); } } } function updatePointerData(data: PointerData, rect: DOMRect) { data.position.set( pointerPosition.x - rect.left, pointerPosition.y - rect.top ); data.nPosition.set( (data.position.x / rect.width) * 2 - 1, (-data.position.y / rect.height) * 2 + 1 ); } function isInside(rect: DOMRect) { return ( pointerPosition.x >= rect.left && pointerPosition.x <= rect.left + rect.width && pointerPosition.y >= rect.top && pointerPosition.y <= rect.top + rect.height ); } interface CreateOrbFieldReturn { three: OrbFieldScene; orbs: OrbFieldMeshes; setCount: (count: number) => void; updateConfig: (params: any) => void; togglePause: () => void; dispose: () => void; } function createOrbField( canvas: HTMLCanvasElement, config: Partial<OrbFieldConfig & PhysicsConfig> = {} ): CreateOrbFieldReturn { const threeInstance = new OrbFieldScene({ canvas, size: "parent", rendererOptions: { antialias: true, alpha: true }, }); let orbs: OrbFieldMeshes; threeInstance.renderer.toneMapping = ACESFilmicToneMapping; threeInstance.camera.position.set(0, 0, 20); threeInstance.camera.lookAt(0, 0, 0); threeInstance.cameraMaxAspect = 1.5; threeInstance.resize(); const initialConfig = { ...config, maxX: threeInstance.size.wWidth / 2, maxY: threeInstance.size.wHeight / 2, }; initialize(initialConfig); const raycaster = new Raycaster(); const plane = new Plane(new Vector3(0, 0, 1), 0); const intersectionPoint = new Vector3(); let isPaused = false; // FIX: Only disable touch action on larger screens to allow scrolling on mobile // We assume mobile/small screens are <= 768px const isMobile = window.matchMedia("(max-width: 768px)").matches; if (!isMobile) { canvas.style.touchAction = "none"; } else { canvas.style.touchAction = "auto"; } canvas.style.userSelect = "none"; (canvas.style as any).webkitUserSelect = "none"; const pointerData = createPointerData({ domElement: canvas, onMove() { raycaster.setFromCamera(pointerData.nPosition, threeInstance.camera); threeInstance.camera.getWorldDirection(plane.normal); raycaster.ray.intersectPlane(plane, intersectionPoint); if (orbs) { orbs.physics.center.copy(intersectionPoint); orbs.config.controlOrb0 = true; } }, onLeave() { if (orbs) { orbs.config.controlOrb0 = false; } }, }); function initialize(cfg: any) { if (orbs) { threeInstance.clear(); threeInstance.scene.remove(orbs); } orbs = new OrbFieldMeshes(threeInstance.renderer, cfg); threeInstance.scene.add(orbs); } threeInstance.onBeforeRender = (deltaInfo) => { if (!isPaused && orbs) orbs.update(deltaInfo); }; threeInstance.onAfterResize = (size) => { if (orbs) { orbs.config.maxX = size.wWidth / 2; orbs.config.maxY = size.wHeight / 2; } }; return { three: threeInstance, get orbs() { return orbs; }, setCount(count: number) { initialize({ ...orbs.config, count }); }, updateConfig(params: Partial<PhysicsConfig>) { if (orbs && params.colors) { orbs.setColors(params.colors); } }, togglePause() { isPaused = !isPaused; }, dispose() { pointerData.dispose?.(); threeInstance.dispose(); }, }; } export const OrbField: React.FC<OrbFieldProps> = ({ className = "", followCursor = true, colors: propColors, ...props }) => { const { resolvedTheme } = useTheme(); const canvasRef = useRef<HTMLCanvasElement>(null); const orbsInstanceRef = useRef<CreateOrbFieldReturn | null>(null); const isLight = resolvedTheme === "light"; const defaultColors = isLight ? [0x18181b, 0x71717a, 0xd4d4d8, 0xffffff] : [0xfafafa, 0xa1a1aa, 0x52525b, 0x09090b]; const colors = propColors || defaultColors; useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; orbsInstanceRef.current = createOrbField(canvas, { followCursor, colors, ...props, }); return () => { if (orbsInstanceRef.current) { orbsInstanceRef.current.dispose(); } }; }, []); useEffect(() => { if (orbsInstanceRef.current) { orbsInstanceRef.current.updateConfig({ colors }); } }, [colors]); return ( <canvas className={`${className} w-full h-full`} style={{ display: "block", background: "transparent" }} ref={canvasRef} /> ); };
Usage
import { OrbField } from "@/components/zenblocks/orb-field";
<OrbField count={200} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | 200 | Number of orbs |
| colors | number[] | [0x000000] | Array of hex colors |
| gravity | number | 0.5 | Gravity force strength |
| friction | number | 0.9975 | Velocity decay factor |
| wallBounce | number | 0.95 | Wall collision elasticity |
| followCursor | boolean | true | Enable cursor interaction |
| minSize | number | 0.5 | Minimum orb size |
| maxSize | number | 1 | Maximum orb size |
| materialParams | object | - | Three.js material properties |
Accessibility
- Canvas labeled with
role="img" - Automatically reduces particle count on
prefers-reduced-motion - Disables cursor interaction when motion is reduced
- WebGL rendering off main thread for screen reader compatibility
Customization
Fluid Dynamics
// Water-like
<OrbField gravity={0.5} friction={0.99} />
// Space-like
<OrbField gravity={0} friction={0.999} />Material Look
<OrbField
materialParams={{
metalness: 0.9,
roughness: 0.1
}}
/>Color Gradient
<OrbField colors={[0xaaaaaa, 0xffffff]} />Motion Behavior
- Physics: Verlet-like integration for particle movement
- Collisions: Elastic collisions with overlap correction
- Cursor tracking: Raycaster projects cursor to 3D plane
- Leader orb: Lerps toward cursor position when
followCursoris true
Performance Notes
- Uses
InstancedMeshfor single draw call rendering - IntersectionObserver pauses simulation when off-screen
- Mobile devices automatically use half particle count
- Rounded box geometry with beveled edges
Examples
Liquid Metal
<OrbField
colors={[0xaaaaaa, 0xffffff]}
materialParams={{ metalness: 1, roughness: 0 }}
/>Low Gravity
<OrbField gravity={0.1} friction={0.998} />Notes
- Requires
threeand@types/threedependencies - Best used as fixed background (
absolute inset-0) - Canvas automatically sizes to parent container
- Touch action disabled on desktop for smooth interaction