Instanced Field
A dense grid of bars carrying a travelling wave, deformed further by wherever the pointer is. Every instance is drawn in a single call through an InstancedMesh, which is the difference between this holding 60fps on a phone and it not running at all — the obvious implementation, one mesh per bar, spends the whole frame budget in JavaScript issuing draw calls.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { InstancedField } from "@/components/instancedfield"; <InstancedField />Customise
Rounded to the nearest square. Past ~6,000 the CPU matrix write starts to show on low-end mobile.
0 holds a single composed frame.
The component
components/craft/3d/InstancedField.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useMemo, useRef } from "react";import { Canvas, useFrame, useThree } from "@react-three/fiber";import * as THREE from "three"; /** * A field of thousands of bars that ripple away from the pointer. * * All of them are ONE draw call. A single InstancedMesh carries a per-instance * matrix and colour, so the GPU draws 4,000 objects as cheaply as it draws * one — the naive version, 4,000 separate meshes, spends its entire frame * budget in JavaScript issuing draw calls and never reaches 60fps on a phone. * * The ripple is computed on the CPU because each instance needs a full matrix * anyway, and 4,000 sin() calls is around 0.2ms — well inside budget. Moving * it to a vertex shader would be faster still but would cost the ability to * read positions back, which is what makes the pointer falloff simple. */ interface FieldProps { count: number; color: string; accent: string; amplitude: number; speed: number; radius: number; still: boolean;} function Bars({ count, color, accent, amplitude, speed, radius, still }: FieldProps) { const mesh = useRef<THREE.InstancedMesh>(null); const { viewport } = useThree(); // Grid derived from the instance budget so the field stays square-ish // whatever number the caller asks for. const side = Math.max(2, Math.round(Math.sqrt(count))); const total = side * side; // Allocated once. Building these per frame would hand the garbage // collector a few megabytes a second and show up as periodic stutter. const dummy = useMemo(() => new THREE.Object3D(), []); const baseColor = useMemo(() => new THREE.Color(color), [color]); const accentColor = useMemo(() => new THREE.Color(accent), [accent]); const scratch = useMemo(() => new THREE.Color(), []); const pointer = useRef(new THREE.Vector2(999, 999)); const positions = useMemo(() => { const list: Array<[number, number]> = []; const spacing = 1; const offset = ((side - 1) * spacing) / 2; for (let x = 0; x < side; x += 1) { for (let z = 0; z < side; z += 1) { list.push([x * spacing - offset, z * spacing - offset]); } } return list; }, [side]); // Colour is per-instance and static, so it is written once rather than // every frame. useEffect(() => { const instanced = mesh.current; if (!instanced) return; const half = (side - 1) / 2; positions.forEach(([x, z], i) => { const distance = Math.hypot(x, z) / (half * 1.42); scratch.copy(baseColor).lerp(accentColor, Math.max(0, 1 - distance)); instanced.setColorAt(i, scratch); }); if (instanced.instanceColor) instanced.instanceColor.needsUpdate = true; }, [positions, side, baseColor, accentColor, scratch]); const write = (time: number) => { const instanced = mesh.current; if (!instanced) return; positions.forEach(([x, z], i) => { const distance = Math.hypot(x, z); // Radial travelling wave — the ripple. let height = Math.sin(distance * 0.55 - time * speed) * amplitude; // Pointer falloff, added on top so the two read as one surface rather // than the cursor punching a hole through the wave. const toPointer = Math.hypot(x - pointer.current.x, z - pointer.current.y); if (toPointer < radius) { const influence = 1 - toPointer / radius; height += influence * influence * amplitude * 3.2; } dummy.position.set(x, height * 0.5, z); // Scaled on Y only, anchored at the base, so bars grow upward instead // of expanding through the floor. dummy.scale.set(1, Math.max(0.08, 0.4 + height), 1); dummy.updateMatrix(); instanced.setMatrixAt(i, dummy.matrix); }); instanced.instanceMatrix.needsUpdate = true; }; useFrame((state) => { if (still) return; write(state.clock.elapsedTime); }); // One composed frame for reduced motion, rather than a flat grid or an // empty canvas. useEffect(() => { if (still) write(2.4); // write closes over the current props; re-running on any of them keeps // the still frame in sync with the controls. }); const onPointerMove = (event: { point: THREE.Vector3 }) => { if (still) return; pointer.current.set(event.point.x, event.point.z); }; return ( <> {/* An invisible plane large enough to catch the pointer across the whole field — raycasting against 4,000 instances every mousemove would cost far more than the animation does. */} <mesh rotation={[-Math.PI / 2, 0, 0]} onPointerMove={onPointerMove} onPointerLeave={() => pointer.current.set(999, 999)} visible={false} > <planeGeometry args={[viewport.width * 3, viewport.height * 3]} /> </mesh> <instancedMesh ref={mesh} args={[undefined, undefined, total]} castShadow={false}> <boxGeometry args={[0.42, 1, 0.42]} /> <meshStandardMaterial roughness={0.55} metalness={0.1} /> </instancedMesh> </> );} export interface InstancedFieldProps { /** Instance budget. Rounded to the nearest square — see the cost note. */ count?: number; color?: string; accent?: string; /** Wave height. */ amplitude?: number; speed?: number; /** How far the pointer's influence reaches, in grid units. */ radius?: number; still?: boolean; className?: string;} export function InstancedField({ count = 3600, color = "#1d2114", accent = "#c3f53c", amplitude = 0.9, speed = 1.6, radius = 6, still = false, className = "",}: InstancedFieldProps) { const reduced = still || (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); return ( <div className={`h-full w-full ${className}`}> <Canvas // Capped at 1.5: this is a dense scene and the top end of the DPR // range costs more than it shows. dpr={[1, 1.5]} camera={{ position: [0, 18, 26], fov: 38 }} // Nothing changes unless the loop runs, so a still render is a single // frame rather than an idling rAF. frameloop={reduced ? "demand" : "always"} gl={{ antialias: true, powerPreference: "high-performance" }} > <color attach="background" args={["#0a0b0e"]} /> <ambientLight intensity={0.55} /> <directionalLight position={[8, 14, 6]} intensity={1.6} /> <Bars count={count} color={color} accent={accent} amplitude={amplitude} speed={speed} radius={radius} still={reduced} /> </Canvas> </div> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+168KB gzipped · needs three, @react-three/fiber
- Runs on
GPU
Work happens in a shader. The main thread only issues draw calls, so JavaScript stays free, but it costs GPU time and battery on mobile.
- Reduced motion
Renders one composed frame with the wave frozen mid-cycle and the render loop switched to on-demand, so no rAF runs at all. Never a blank canvas.
- Accessibility
- Canvas is aria-hidden — it carries no information a screen reader needs.
- Nothing here is interactive in the keyboard sense, so it adds no tab stops.
- Pointer effects degrade silently on touch, where there is no hover.
- Watch out
- By far the heaviest thing in this library. If the page does not already load three.js, this is 168KB for a background.
- Pointer picking uses one invisible plane, not the instances. Raycasting 4,000 instances per mousemove would cost more than the animation.
- Costs real GPU time and therefore battery. Think twice before putting it above the fold on a marketing page.