Skip to content
Docs
Boids Ecosystem

Boids Ecosystem

A canvas flocking simulation — separation, alignment, cohesion — that parts around your cursor like a school of fish.

new
Loading...

Installation

CLI

pnpm dlx shadcn@latest add https://animata.design/r/background/boids-ecosystem.json

Manual

Run the following command

mkdir -p components/animata/background && touch components/animata/background/boids-ecosystem.tsx

Paste the code

"use client";
 
import { type RefObject, useEffect, useRef } from "react";
 
import { cn } from "@/lib/utils";
 
export interface BoidAgent {
  x: number;
  y: number;
  vx: number;
  vy: number;
  color: string;
}
 
/**
 * Force point that pulls boids toward (x, y). Coordinates are normalized 0..1
 * across the canvas, so callers don't need to know the canvas pixel size.
 */
export interface BoidAttractor {
  x: number;
  y: number;
  /** 0 = no pull, 0.05 = gentle, 0.15 = strong. Default 0.05. */
  strength?: number;
  /** Pixel radius of influence. Default 220. */
  radius?: number;
}
 
interface BoidsEcosystemProps {
  count?: number;
  background?: string;
  palette?: string[];
  cursorRadius?: number;
  /** Force points the flock is drawn toward. Read live, safe to mutate by ref. */
  attractors?: BoidAttractor[];
  /**
   * Receives the live agents array on mount. Mutate-in-place by the simulation;
   * read positions from animation frames in the parent (do NOT trigger React state from this).
   */
  agentsRef?: RefObject<BoidAgent[] | null>;
  /** Triangle reads as flock; dot reads as ambient activity. */
  agentShape?: "triangle" | "dot";
  className?: string;
  children?: React.ReactNode;
}
 
const DEFAULT_PALETTE = ["#f5f5f4", "#fde68a", "#93c5fd", "#fca5a5"];
 
export default function BoidsEcosystem({
  count = 120,
  background = "#0b0b12",
  palette = DEFAULT_PALETTE,
  cursorRadius = 90,
  attractors,
  agentsRef,
  agentShape = "triangle",
  className,
  children,
}: BoidsEcosystemProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const cursorRef = useRef<{ x: number; y: number; active: boolean }>({
    x: 0,
    y: 0,
    active: false,
  });
  // All non-`count` props read from this ref each frame, so prop changes
  // don't tear down the simulation.
  const liveRef = useRef({ background, palette, cursorRadius, attractors, agentShape });
  liveRef.current = { background, palette, cursorRadius, attractors, agentShape };
 
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
 
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const agents: BoidAgent[] = [];
 
    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
 
    const w = () => canvas.width / dpr;
    const h = () => canvas.height / dpr;
 
    const initialPalette = liveRef.current.palette;
    for (let i = 0; i < count; i++) {
      const a = Math.random() * Math.PI * 2;
      const s = 0.6 + Math.random() * 0.6;
      agents.push({
        x: Math.random() * w(),
        y: Math.random() * h(),
        vx: Math.cos(a) * s,
        vy: Math.sin(a) * s,
        color: initialPalette[Math.floor(Math.random() * initialPalette.length)],
      });
    }
    if (agentsRef) agentsRef.current = agents;
 
    const perception = 36;
    const maxSpeed = 1.8;
 
    let raf = 0;
    const step = () => {
      const { background: bg, cursorRadius: cr, attractors: atts } = liveRef.current;
      const W = w();
      const H = h();
 
      ctx.fillStyle = `${bg}dd`;
      ctx.fillRect(0, 0, W, H);
 
      for (const a of agents) {
        let ax = 0;
        let ay = 0;
        let sx = 0;
        let sy = 0;
        let cx = 0;
        let cy = 0;
        let n = 0;
 
        for (const b of agents) {
          if (a === b) continue;
          const dx = b.x - a.x;
          const dy = b.y - a.y;
          const d2 = dx * dx + dy * dy;
          if (d2 < perception * perception) {
            ax += b.vx;
            ay += b.vy;
            cx += b.x;
            cy += b.y;
            if (d2 < 18 * 18 && d2 > 0.0001) {
              const d = Math.sqrt(d2);
              sx -= dx / d;
              sy -= dy / d;
            }
            n++;
          }
        }
        if (n > 0) {
          a.vx += (ax / n - a.vx) * 0.04;
          a.vy += (ay / n - a.vy) * 0.04;
          a.vx += (cx / n - a.x) * 0.0006;
          a.vy += (cy / n - a.y) * 0.0006;
          a.vx += sx * 0.06;
          a.vy += sy * 0.06;
        }
 
        if (atts?.length) {
          for (const att of atts) {
            const tx = att.x * W;
            const ty = att.y * H;
            const dx = tx - a.x;
            const dy = ty - a.y;
            const d = Math.hypot(dx, dy) || 1;
            const radius = att.radius ?? 220;
            if (d < radius) {
              const f = ((radius - d) / radius) * (att.strength ?? 0.05);
              a.vx += (dx / d) * f;
              a.vy += (dy / d) * f;
            }
          }
        }
 
        if (cursorRef.current.active) {
          const dx = a.x - cursorRef.current.x;
          const dy = a.y - cursorRef.current.y;
          const d = Math.hypot(dx, dy);
          if (d < cr && d > 0) {
            const f = (cr - d) / cr;
            a.vx += (dx / d) * f * 0.6;
            a.vy += (dy / d) * f * 0.6;
          }
        }
 
        const sp = Math.hypot(a.vx, a.vy);
        if (sp > maxSpeed) {
          a.vx = (a.vx / sp) * maxSpeed;
          a.vy = (a.vy / sp) * maxSpeed;
        }
        a.x += a.vx;
        a.y += a.vy;
 
        if (a.x < 0) a.x += W;
        if (a.x > W) a.x -= W;
        if (a.y < 0) a.y += H;
        if (a.y > H) a.y -= H;
 
        ctx.fillStyle = a.color;
        ctx.globalAlpha = 0.88;
        if (liveRef.current.agentShape === "dot") {
          ctx.beginPath();
          ctx.arc(a.x, a.y, 1.8, 0, Math.PI * 2);
          ctx.fill();
        } else {
          const angle = Math.atan2(a.vy, a.vx);
          ctx.save();
          ctx.translate(a.x, a.y);
          ctx.rotate(angle);
          ctx.beginPath();
          ctx.moveTo(6, 0);
          ctx.lineTo(-4, 3);
          ctx.lineTo(-4, -3);
          ctx.closePath();
          ctx.fill();
          ctx.restore();
        }
      }
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
 
    // Listen on window so events bubble through any overlay (links, content
    // wrappers, etc.). Bounds-check inside the handler so the flock only
    // reacts when the cursor is within the canvas itself.
    const onMove = (e: PointerEvent) => {
      const r = canvas.getBoundingClientRect();
      const x = e.clientX - r.left;
      const y = e.clientY - r.top;
      cursorRef.current.x = x;
      cursorRef.current.y = y;
      cursorRef.current.active = x >= 0 && x <= r.width && y >= 0 && y <= r.height;
    };
    const onPageLeave = () => {
      cursorRef.current.active = false;
    };
    window.addEventListener("pointermove", onMove);
    document.documentElement.addEventListener("pointerleave", onPageLeave);
    window.addEventListener("blur", onPageLeave);
 
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      window.removeEventListener("pointermove", onMove);
      document.documentElement.removeEventListener("pointerleave", onPageLeave);
      window.removeEventListener("blur", onPageLeave);
      if (agentsRef) agentsRef.current = null;
    };
  }, [count, agentsRef]);
 
  return (
    <div className={cn("relative h-full w-full overflow-hidden rounded-lg", className)}>
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" style={{ background }} />
      {children && (
        <div className="relative z-10 flex h-full items-center justify-center pointer-events-none">
          {children}
        </div>
      )}
    </div>
  );
}

Credits

Built by hari