Skip to content
3D

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.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { InstancedField } from "@/components/instancedfield"; <InstancedField />

Customise

3600

Rounded to the nearest square. Past ~6,000 the CPU matrix write starts to show on low-end mobile.

0.9
1.6

0 holds a single composed frame.

6

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.