Flocking Birds Canvas Background That Scatters From Pointer

A dark hero with a living flock behind it. About 200 small arrow shapes follow the three classic flocking rules: steer the same way as neighbours, drift toward the middle of the group and keep a little distance. Two slow invisible points pull the flock around the screen so it keeps wheeling without input, and the birds turn away from the pointer or a dragged finger. A soft dark pool sits behind the headline so the copy stays readable. On phones the flock is halved and the buttons stack.

Code

// Adapted from Animata (https://animata.design/docs/background/boids-ecosystem)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import { ArrowRight, Bird } from "lucide-react";

const settings = {
  count: 200, // birds on desktop; phones get about half
  background: "#07131f",
  palette: ["#e2f3f5", "#7dd3fc", "#5eead4", "#fcd34d"],
  cursorRadius: 110, // how close the pointer gets before birds turn away
  maxSpeed: 1.9,
};

const facts = [
  { value: "214", label: "species logged" },
  { value: "38", label: "hides and trails" },
  { value: "5:40", label: "first light today" },
];

type Agent = { x: number; y: number; vx: number; vy: number; color: string };

export default function FlockingBirdsCanvasBackground() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    let W = 0;
    let H = 0;
    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      W = rect.width;
      H = rect.height;
      canvas.width = Math.round(W * dpr);
      canvas.height = Math.round(H * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();

    const count = W < 640 ? Math.round(settings.count * 0.5) : settings.count;
    const agents: Agent[] = [];
    for (let i = 0; i < count; i++) {
      const angle = Math.random() * Math.PI * 2;
      const speed = 0.6 + Math.random() * 0.6;
      agents.push({
        x: Math.random() * W,
        y: Math.random() * H,
        vx: Math.cos(angle) * speed,
        vy: Math.sin(angle) * speed,
        color: settings.palette[i % settings.palette.length],
      });
    }

    const cursor = { x: 0, y: 0, active: false };
    const perception = 38;
    let tick = 0;

    // One step of the simulation: align with neighbours, drift to their centre, keep a little distance.
    const update = () => {
      tick += 1;
      // Two slow wandering points keep the flock wheeling when nobody is touching it.
      const lures = [
        { x: W * (0.5 + 0.38 * Math.cos(tick * 0.004)), y: H * (0.5 + 0.34 * Math.sin(tick * 0.0056)) },
        { x: W * (0.5 + 0.4 * Math.cos(tick * 0.003 + 2.4)), y: H * (0.5 + 0.3 * Math.sin(tick * 0.0045 + 1.2)) },
      ];
      for (const a of agents) {
        let alignX = 0;
        let alignY = 0;
        let centreX = 0;
        let centreY = 0;
        let pushX = 0;
        let pushY = 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) {
            alignX += b.vx;
            alignY += b.vy;
            centreX += b.x;
            centreY += b.y;
            if (d2 < 18 * 18 && d2 > 0.0001) {
              const d = Math.sqrt(d2);
              pushX -= dx / d;
              pushY -= dy / d;
            }
            n++;
          }
        }
        if (n > 0) {
          a.vx += (alignX / n - a.vx) * 0.04 + (centreX / n - a.x) * 0.0006 + pushX * 0.06;
          a.vy += (alignY / n - a.vy) * 0.04 + (centreY / n - a.y) * 0.0006 + pushY * 0.06;
        }
        for (const lure of lures) {
          const dx = lure.x - a.x;
          const dy = lure.y - a.y;
          const d = Math.hypot(dx, dy) || 1;
          if (d < 260) {
            const f = ((260 - d) / 260) * 0.035;
            a.vx += (dx / d) * f;
            a.vy += (dy / d) * f;
          }
        }
        if (cursor.active) {
          const dx = a.x - cursor.x;
          const dy = a.y - cursor.y;
          const d = Math.hypot(dx, dy);
          if (d < settings.cursorRadius && d > 0) {
            const f = (settings.cursorRadius - d) / settings.cursorRadius;
            a.vx += (dx / d) * f * 0.6;
            a.vy += (dy / d) * f * 0.6;
          }
        }
        const speed = Math.hypot(a.vx, a.vy);
        if (speed > settings.maxSpeed) {
          a.vx = (a.vx / speed) * settings.maxSpeed;
          a.vy = (a.vy / speed) * settings.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;
      }
    };

    const draw = (trail: boolean) => {
      ctx.globalAlpha = 1;
      ctx.fillStyle = trail ? `${settings.background}cc` : settings.background;
      ctx.fillRect(0, 0, W, H);
      ctx.globalAlpha = 0.9;
      for (const a of agents) {
        ctx.fillStyle = a.color;
        ctx.save();
        ctx.translate(a.x, a.y);
        ctx.rotate(Math.atan2(a.vy, a.vx));
        ctx.beginPath();
        ctx.moveTo(7, 0);
        ctx.lineTo(-4, 3.2);
        ctx.lineTo(-2, 0);
        ctx.lineTo(-4, -3.2);
        ctx.closePath();
        ctx.fill();
        ctx.restore();
      }
    };

    // Let the flock form before the first paint so it never starts as random noise.
    for (let i = 0; i < 220; i++) update();
    draw(false);

    let raf = 0;
    let onScreen = true;
    const loop = () => {
      update();
      draw(true);
      raf = requestAnimationFrame(loop);
    };
    const start = () => {
      cancelAnimationFrame(raf);
      if (!reduced && onScreen && !document.hidden) raf = requestAnimationFrame(loop);
    };
    start();

    const ro = new ResizeObserver(() => {
      resize();
      if (reduced) draw(false);
    });
    ro.observe(canvas);
    const io = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      start();
    });
    io.observe(canvas);

    const onMove = (e: PointerEvent) => {
      const r = canvas.getBoundingClientRect();
      cursor.x = e.clientX - r.left;
      cursor.y = e.clientY - r.top;
      cursor.active = cursor.x >= 0 && cursor.x <= r.width && cursor.y >= 0 && cursor.y <= r.height;
    };
    const onLeave = () => {
      cursor.active = false;
    };
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerdown", onMove);
    window.addEventListener("pointerup", onLeave);
    window.addEventListener("blur", onLeave);
    document.addEventListener("visibilitychange", start);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerdown", onMove);
      window.removeEventListener("pointerup", onLeave);
      window.removeEventListener("blur", onLeave);
      document.removeEventListener("visibilitychange", start);
    };
  }, []);

  return (
    <section className="relative isolate overflow-hidden" style={{ background: settings.background }}>
      <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 h-full w-full" />
      {/* Soft dark pool behind the copy so the birds never fight the text. */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
        style={{ background: "radial-gradient(ellipse 60% 55% at 50% 48%, rgba(7,19,31,0.88) 0%, rgba(7,19,31,0.55) 55%, rgba(7,19,31,0) 100%)" }}
      />
      <div className="relative mx-auto flex min-h-[620px] max-w-3xl flex-col items-center justify-center px-6 py-20 text-center">
        <p className="inline-flex items-center gap-2 rounded-full border border-teal-300/30 bg-teal-300/10 px-3.5 py-1.5 text-xs font-medium uppercase tracking-widest text-teal-200">
          <Bird aria-hidden="true" className="h-4 w-4" />
          Saltmarsh Reserve
        </p>
        <h1 className="mt-6 text-4xl font-semibold tracking-tight text-white sm:text-6xl">
          Forty thousand wings, one&nbsp;evening sky.
        </h1>
        <p className="mt-5 max-w-xl text-base leading-relaxed text-slate-300 sm:text-lg">
          The starling roost returns to the reedbeds every dusk from October to March. Book a place on the boardwalk and
          watch the flock turn overhead.
        </p>
        <div className="mt-8 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
          <a
            href="#"
            className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-full bg-teal-300 px-6 text-sm font-semibold text-slate-950 transition-colors hover:bg-teal-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 sm:w-auto"
          >
            Book a dusk walk
            <ArrowRight aria-hidden="true" className="h-4 w-4" />
          </a>
          <a
            href="#"
            className="inline-flex h-12 w-full items-center justify-center rounded-full border border-white/25 px-6 text-sm font-semibold text-white transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white sm:w-auto"
          >
            See this week&apos;s sightings
          </a>
        </div>
        <dl className="mt-12 grid w-full max-w-md grid-cols-3 gap-4 border-t border-white/15 pt-6">
          {facts.map((fact) => (
            <div key={fact.label}>
              <dt className="sr-only">{fact.label}</dt>
              <dd className="text-2xl font-semibold tabular-nums text-white">{fact.value}</dd>
              <dd className="mt-1 text-xs text-slate-300">{fact.label}</dd>
            </div>
          ))}
        </dl>
        <p className="mt-8 text-xs text-slate-400">Move your pointer or drag a finger across the sky to scatter the flock.</p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/flocking-birds-canvas-background-that-scatters-from-pointer.json

When to use it

How to customize it

Accessibility

Questions

What are boids?

Boids is the name of a simple model of flocking published in the 1980s. Each bird looks only at its close neighbours and follows three rules: alignment, cohesion and separation. Lifelike flock movement appears from those rules alone, with no leader and no scripted path.

Is this heavy on the processor?

Each frame compares every bird with every other bird, which is about 40,000 quick checks for 200 birds. That stays well under two milliseconds on ordinary hardware. If you raise `count` far past 400 the cost grows with the square of the number, so test on a phone.

Why does the flock already look formed when the page loads?

Before the first paint the effect runs 220 steps of the simulation without drawing. That lets groups form, so the first frame shows a flock and not scattered noise.

Does it work on touch screens?

Yes. It listens for pointer events, which cover mouse, pen and touch. Pressing or dragging a finger scatters the birds near it, and lifting the finger lets them regroup.

Can I put it behind a different section?

Yes. Keep the `canvas` with `absolute inset-0` as the first child of any `relative overflow-hidden` block and place your own content after it in a `relative` wrapper.

Adapted from Boids Ecosystem in Animata, used under its open-source licence. Licences and credits

Related: Particle Network Background with Connecting Lines, Particle Flow Field Canvas Background, Starfield Background with Depth and Shooting Stars.

More animated backgrounds or all free UI components. Start for free to have a full site built around it.

More in Backgrounds

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap