Innovations
heroes /

Particle Field Hero (SaaS / Product / Event)

Dark photographic ground with a drifting canvas particle field over it, a transparent nav laid on top, and a centred three-tier headline stack — big claim, accent qualifier, small condensed kicker. Particles twinkle on their own clocks, clear a space around the cursor, pause when the hero scrolls out of view, and hold still under reduced motion. No library: about 70 lines of canvas.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

/**
 * Particle Field hero — dark photographic ground, drifting canvas particles,
 * a transparent nav laid over the top, and a centred three-tier headline.
 *
 * Measured off mfdsgn.com's hero rather than eyeballed: 1440x727 at
 * 86px/160px vertical padding, a cover-fitted dark background, and a particle
 * layer whose live configuration read 250 white circles, radius 0-6 random,
 * opacity 0-0.5 random with a slow twinkle, drift speed 12 in random
 * directions, NO linking lines, hover set to "bubble" at size 0 / opacity 0,
 * and click set to repulse.
 *
 * Everything below is our own code and our own background. In particular the
 * particles are ~70 lines of canvas rather than particles.js: the library is
 * ~50KB for decoration, and writing it means the hover and click behaviours can
 * be tuned instead of configured.
 */

export interface ParticleFieldHeroProps {
  brand?: string;
  navLinks?: { label: string; href: string }[];
  navCta?: { label: string; href: string };
  /** Dark, low-contrast and abstract. See the note on legibility below. */
  imageSrc?: string | null;
  /** Shown when imageSrc is null, and behind it while it loads. */
  fallbackFrom?: string;
  fallbackTo?: string;
  /** The three tiers. Each is optional; the stack closes up without it. */
  headline?: string;
  subhead?: string;
  kicker?: string;
  headlineColor?: string;
  subheadColor?: string;
  kickerColor?: string;
  cta?: { label: string; href: string } | null;
  /** Particles per 800x800 of canvas, so the field looks the same density on a
   *  phone and on a 27-inch monitor. The reference's own config works this way. */
  density?: number;
  particleColor?: string;
  /** Max radius in px. Each particle gets a random fraction of it. */
  particleSize?: number;
  /** Drift speed. The reference runs 12, which is faster than it sounds
   *  because most particles are under 2px. */
  speed?: number;
  /** Radius in px around the cursor where particles fade away. 0 disables. */
  clearRadius?: number;
  minHeightVh?: number;
  /** Layered wave transition at the bottom. Three offset paths in the next
   *  section's colour, back two faded, so the edge has depth rather than being
   *  one flat cut. Pass 0 for a straight edge. */
  waveHeight?: number;
  /** MUST match whatever follows: the waves are drawn IN that colour — they are
   *  a bite taken out of the hero, not a shape laid over it. */
  nextSectionColor?: string;
}

interface P {
  x: number; y: number; vx: number; vy: number;
  r: number; base: number; a: number; dir: number; hidden: number;
}

export default function ParticleFieldHero({
  brand = "Northpoint",
  navLinks = [
    { label: "Features", href: "#features" },
    { label: "Pricing", href: "#pricing" },
    { label: "Docs", href: "#docs" },
  ],
  navCta = { label: "Get started", href: "#start" },
  imageSrc = "/heroes/particle-field/bg.webp",
  fallbackFrom = "#2A1148",
  fallbackTo = "#0C0616",
  headline = "Build it once. Ship it everywhere.",
  subhead = "One codebase, every surface",
  kicker = "Now in public beta",
  headlineColor = "#FFFFFF",
  subheadColor = "#6DDDFF",
  kickerColor = "#F6B6E8",
  cta = { label: "Start building", href: "#start" },
  density = 34,
  particleColor = "#FFFFFF",
  particleSize = 6,
  speed = 12,
  clearRadius = 130,
  minHeightVh = 90,
  waveHeight = 160,
  nextSectionColor = "#FFFFFF",
}: ParticleFieldHeroProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const hostRef = useRef<HTMLElement | null>(null);

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

    const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;

    let w = 0, h = 0, dpr = 1;
    let parts: P[] = [];
    const pointer = { x: -9999, y: -9999, on: false };
    let raf = 0;
    let running = false;

    const rand = (a: number, b: number) => a + Math.random() * (b - a);

    const build = () => {
      const r = host.getBoundingClientRect();
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      w = Math.max(1, Math.round(r.width));
      h = Math.max(1, Math.round(r.height));
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      canvas.style.width = w + "px";
      canvas.style.height = h + "px";
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

      // Density, not a fixed count: the same visual field at any width.
      const n = Math.round((w * h) / (800 * 800) * density * 8);
      parts = Array.from({ length: n }, () => {
        const base = rand(0.35, 1) * particleSize * 0.5;
        const ang = rand(0, Math.PI * 2);
        const sp = (speed / 60) * rand(0.35, 1);
        return {
          x: rand(0, w), y: rand(0, h),
          vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
          r: base, base,
          a: rand(0, 0.5),
          // Each particle twinkles on its own clock; a shared one pulses the
          // whole field at once and reads as a flicker rather than as stars.
          dir: Math.random() > 0.5 ? 1 : -1,
          hidden: 0,
        };
      });
    };

    const draw = () => {
      ctx.clearRect(0, 0, w, h);
      ctx.fillStyle = particleColor;
      for (const p of parts) {
        if (p.a <= 0.001 || p.r <= 0.05) continue;
        ctx.globalAlpha = p.a;
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalAlpha = 1;
    };

    const step = () => {
      for (const p of parts) {
        p.x += p.vx;
        p.y += p.vy;

        // Leave and re-enter from the opposite edge. The reference uses
        // particles.js "out" mode, which respawns at a random edge; wrapping
        // looks the same and never leaves a bald patch mid-animation.
        if (p.x < -10) p.x = w + 10; else if (p.x > w + 10) p.x = -10;
        if (p.y < -10) p.y = h + 10; else if (p.y > h + 10) p.y = -10;

        // Twinkle: opacity walks between 0 and 0.5 and turns round at the ends.
        p.a += p.dir * 0.0045;
        if (p.a <= 0) { p.a = 0; p.dir = 1; }
        else if (p.a >= 0.5) { p.a = 0.5; p.dir = -1; }

        // Cursor clears a space. The reference does this via a "bubble" mode set
        // to size 0 and opacity 0, which is a strange way to say "get out of the
        // way" — but it is the nicest bit of the whole effect, so it is kept.
        let target = 0;
        if (pointer.on && clearRadius > 0) {
          const dx = p.x - pointer.x, dy = p.y - pointer.y;
          const d2 = dx * dx + dy * dy;
          const R = clearRadius;
          if (d2 < R * R) target = 1 - Math.sqrt(d2) / R;
        }
        p.hidden += (target - p.hidden) * 0.12;
        p.r = p.base * (1 - p.hidden);
      }
      draw();
      raf = requestAnimationFrame(step);
    };

    const start = () => {
      if (running) return;
      running = true;
      raf = requestAnimationFrame(step);
    };
    const stop = () => {
      running = false;
      cancelAnimationFrame(raf);
    };

    build();
    if (reduced) {
      // Reduced motion still gets the field — it is most of the design — just
      // held still. A blank hero is a worse answer than a static one.
      for (const p of parts) p.a = rand(0.1, 0.4);
      draw();
    } else {
      // Never animate a hero nobody is looking at. A rAF loop left running on a
      // scrolled-past section is pure battery for zero pixels.
      const io = new IntersectionObserver(
        (entries) => { entries[0]?.isIntersecting ? start() : stop(); },
        { threshold: 0 }
      );
      io.observe(host);

      const onMove = (e: PointerEvent) => {
        const r = host.getBoundingClientRect();
        pointer.x = e.clientX - r.left;
        pointer.y = e.clientY - r.top;
        pointer.on = true;
      };
      const onLeave = () => { pointer.on = false; };
      host.addEventListener("pointermove", onMove);
      host.addEventListener("pointerleave", onLeave);

      let rt = 0;
      const onResize = () => {
        clearTimeout(rt);
        rt = window.setTimeout(() => { build(); draw(); }, 150);
      };
      window.addEventListener("resize", onResize);

      return () => {
        io.disconnect();
        stop();
        clearTimeout(rt);
        host.removeEventListener("pointermove", onMove);
        host.removeEventListener("pointerleave", onLeave);
        window.removeEventListener("resize", onResize);
      };
    }
  }, [density, particleColor, particleSize, speed, clearRadius]);

  const css = `
    .pfd { position: relative; overflow: hidden; isolation: isolate;
      min-height: ${minHeightVh}vh;
      display: flex; flex-direction: column;
      background: linear-gradient(160deg, ${fallbackFrom} 0%, ${fallbackTo} 100%);
      font-family: 'Barlow', system-ui, sans-serif; }
    .pfd, .pfd * { box-sizing: border-box; }

    .pfd__bg { position: absolute; inset: 0; z-index: 0;
      background-size: cover; background-position: center; background-repeat: no-repeat; }
    /* The canvas is decoration and must never eat a click meant for the nav or
       the button underneath it. Pointer events are read on the SECTION instead,
       which also means the cursor still clears particles while hovering a link. */
    .pfd__canvas { position: absolute; inset: 0; z-index: 1; pointer-events: none;
      display: block; }

    /* THE WAVE STACK, matched to the structure of the reference's own divider.
       THE FIRST VERSION WAS WRONG IN A SPECIFIC WAY: its three paths were the
       same curve offset vertically, which reads as three parallel lines rather
       than as surf. Real layered waves CROSS — each layer's crests and troughs
       sit at different x positions, so the rear layer surfaces above the front
       one in places and disappears behind it in others. The paths below are
       authored to do that; there is a test of it in the meta notes.

       Structural numbers taken from the reference: three layers at 0.33 / 0.66
       / 1 opacity, 160px tall, viewBox 0 0 1000 100 with preserveAspectRatio
       none so one path stretches to any width without deepening.

       overflow:hidden + line-height:0 on the wrapper, and the svg is 1.3px
       wider than the box: at some zoom levels sub-pixel rounding leaves a
       hairline of hero colour down the right edge otherwise. */
    .pfd__waves { position: absolute; left: 0; right: 0; bottom: -1px; z-index: 4;
      height: ${waveHeight}px; overflow: hidden; line-height: 0; pointer-events: none; }
    .pfd__waves svg { display: block; width: calc(100% + 1.3px); height: 100%; }

    .pfd__nav { position: relative; z-index: 3; height: 82px; flex: none;
      display: flex; align-items: center; gap: 30px;
      padding: 0 clamp(20px, 4vw, 56px); }
    .pfd__brand { font-size: 1.25rem; font-weight: 700; letter-spacing: -.02em;
      color: #fff; text-decoration: none; margin-right: auto; }
    .pfd__nav a:not(.pfd__brand):not(.pfd__navcta) {
      color: rgba(255,255,255,.78); text-decoration: none; font-size: .93rem;
      font-weight: 500; transition: color .2s ease; }
    .pfd__nav a:not(.pfd__brand):not(.pfd__navcta):hover { color: #fff; }
    .pfd__navcta { border: 1px solid rgba(255,255,255,.4); border-radius: 999px;
      padding: 9px 20px; color: #fff; text-decoration: none; font-size: .88rem;
      font-weight: 600; transition: background .2s ease, border-color .2s ease; }
    .pfd__navcta:hover { background: rgba(255,255,255,.14); border-color: rgba(255,255,255,.7); }

    /* Measured: 86px above, 160px below. The asymmetry is what stops the stack
       looking like it is floating in the middle of nothing. */
    .pfd__body { position: relative; z-index: 3; flex: 1;
      display: flex; flex-direction: column; align-items: center; justify-content: center;
      text-align: center;
      /* Bottom padding clears the wave stack, so the CTA never sits in the surf. */
      padding: 86px clamp(20px, 5vw, 56px) calc(120px + ${waveHeight}px); }

    /* THE THREE TIERS, and the order is the whole idea: the biggest line is the
       claim, the second is the qualifier in an accent, the third is the smallest
       and lightest. Reference ran 47 / 35 / 20px at line-height 1 — set solid,
       which is what makes the stack read as one block rather than as three
       paragraphs. */
    .pfd__h { margin: 0; font-size: clamp(2rem, 4.2vw, 3.4rem); font-weight: 800;
      line-height: 1.04; letter-spacing: -.02em; color: ${headlineColor};
      max-width: 20ch; text-wrap: balance; }
    .pfd__sub { margin: 18px 0 0; font-size: clamp(1.25rem, 2.6vw, 2.1rem);
      font-weight: 700; line-height: 1.1; letter-spacing: -.01em;
      color: ${subheadColor}; max-width: 26ch; text-wrap: balance; }
    .pfd__kicker { margin: 20px 0 0; font-family: 'Barlow Condensed', 'Barlow', system-ui, sans-serif;
      font-size: clamp(1rem, 1.5vw, 1.2rem); font-weight: 600; line-height: 1.2;
      letter-spacing: .06em; text-transform: uppercase; color: ${kickerColor}; }
    .pfd__cta { display: inline-flex; align-items: center; gap: 10px; margin-top: 34px;
      background: #fff; color: #14101E; text-decoration: none; border-radius: 999px;
      padding: 15px 30px; font-size: .95rem; font-weight: 700;
      transition: transform .25s ease, box-shadow .25s ease; }
    .pfd__cta:hover { transform: translateY(-2px); box-shadow: 0 14px 30px rgba(0,0,0,.35); }
    .pfd__cta span { transition: transform .25s ease; }
    .pfd__cta:hover span { transform: translateX(3px); }

    @media (max-width: 800px) {
      .pfd__nav { gap: 14px; }
      .pfd__nav a:not(.pfd__brand):not(.pfd__navcta) { display: none; }
      .pfd__body { padding: 56px 20px calc(56px + ${waveHeight}px); }
    }
  `;

  return (
    <section className="pfd" ref={hostRef}>
      <link rel="preconnect" href="https://fonts.googleapis.com" />
      <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
      <link
        rel="stylesheet"
        href="https://fonts.googleapis.com/css2?family=Barlow:wght@500;700;800&family=Barlow+Condensed:wght@600&display=swap"
      />
      <style>{css}</style>

      {imageSrc && (
        <div className="pfd__bg" style={{ backgroundImage: `url(${imageSrc})` }} aria-hidden="true" />
      )}
      <canvas className="pfd__canvas" ref={canvasRef} aria-hidden="true" />

      <header className="pfd__nav">
        <a className="pfd__brand" href="#top">{brand}</a>
        {navLinks.map((n) => (
          <a key={n.href + n.label} href={n.href}>{n.label}</a>
        ))}
        {navCta && <a className="pfd__navcta" href={navCta.href}>{navCta.label}</a>}
      </header>

      <div className="pfd__body">
        {headline && <h1 className="pfd__h">{headline}</h1>}
        {subhead && <p className="pfd__sub">{subhead}</p>}
        {kicker && <p className="pfd__kicker">{kicker}</p>}
        {cta && (
          <a className="pfd__cta" href={cta.href}>
            {cta.label} <span aria-hidden="true">&rarr;</span>
          </a>
        )}
      </div>

      {waveHeight > 0 && (
        <div className="pfd__waves" aria-hidden="true">
          {/* preserveAspectRatio="none" is what lets one path stretch to any
              width without the curve deepening on a wide monitor. */}
          <svg viewBox="0 0 1000 100" preserveAspectRatio="none">
            {/* REAR — the highest crests, so it surfaces above the others around
                x=620 and vanishes behind them near x=300. */}
            <path
              d="M0,34 C110,6 208,54 322,46 C446,37 520,2 646,18 C772,34 868,62 1000,38 L1000,100 L0,100 Z"
              fill={nextSectionColor} opacity=".33"
            />
            {/* MIDDLE — crests deliberately out of phase with the rear layer, so
                the two cross rather than running parallel. */}
            <path
              d="M0,56 C146,78 226,26 368,38 C510,50 566,80 706,66 C824,54 902,22 1000,58 L1000,100 L0,100 Z"
              fill={nextSectionColor} opacity=".66"
            />
            {/* FRONT — the only fully opaque one, and its crests are placed
                where the MIDDLE layer troughs (near x=170 and x=810) so the two
                genuinely cross. Drawn flatter than this it sits strictly below
                the middle layer and the pair reads as parallel lines, which is
                exactly how the first version of this looked wrong. */}
            <path
              d="M0,64 C60,46 112,40 172,48 C252,58 322,84 392,86 C472,88 562,70 652,54 C712,44 762,40 812,48 C872,58 932,82 1000,76 L1000,100 L0,100 Z"
              fill={nextSectionColor}
            />
          </svg>
        </div>
      )}
    </section>
  );
}
Claude Code Instructions

CLI Install

npx innovations add particle-field

Where to use it

Use this for SaaS, developer tools, product launches, conferences and anything that wants to feel technical and a bit cosmic. It is the opposite register to the pastel and cut-out heroes in this registry: no people, no warmth, all atmosphere. In Astro: --- import ParticleFieldHero from '../components/innovations/heroes/particle-field'; --- <ParticleFieldHero client:load /> It needs client:load (or client:visible). The particle field is canvas drawn in an effect; server-rendered alone you get the background and the type, which is a perfectly acceptable no-JS fallback but not the design. THE BACKGROUND MUST BE DARK, LOW-CONTRAST AND ABSTRACT, and this is the one thing that decides whether the hero works. The type is white with a cyan and a pink accent sitting directly on the image with no scrim, so a busy or bright photograph destroys it. Aim for a median luminance around 30-40 out of 255 — the shipped default measures 30. If a client insists on a photograph with real subject matter in it, add a dark overlay before you add anything else, or move to a different hero. Pass imageSrc={null} to fall back to the fallbackFrom/fallbackTo gradient, which is a legitimate way to ship this with no image at all. THE THREE TIERS ARE THE COMPOSITION: headline (biggest, white, the claim), subhead (accent colour, the qualifier), kicker (smallest, condensed, uppercase, letterspaced). Each is optional and the stack closes up without it, but dropping to one line leaves a lot of empty dark space that the design does not otherwise fill. If you only have one line to say, use a different hero. Particle props: density is particles per 800x800 of canvas, NOT a fixed count — so the field looks equally dense on a phone and a 27-inch monitor. particleSize is a maximum; each particle takes a random fraction of it, which is what stops the field looking like a grid of identical dots. speed of 12 sounds fast and is not, because most particles end up under 2px. clearRadius is how far around the cursor particles fade out; set 0 to disable. Three things in the implementation that exist for a reason and should survive edits: · Each particle twinkles on its OWN clock. Share one and the whole field pulses together, which reads as a flicker rather than as stars. · The canvas is pointer-events: none and the pointer is read on the SECTION. Otherwise the canvas swallows clicks meant for the nav and the button underneath it, and the cursor stops clearing particles the moment it crosses a link. · An IntersectionObserver stops the loop when the hero scrolls away. A requestAnimationFrame loop left running on a section nobody can see is pure battery for zero pixels. Under prefers-reduced-motion the field is drawn ONCE and held still rather than removed. It is most of the design; a blank hero is a worse answer than a static one. On the typeface: the reference this was measured from sets Roboto, and Barlow is used here instead. That is deliberate rather than sloppy — Roboto is on so many sites it reads as no choice at all, and Barlow keeps the same heavy-grotesque plus condensed-accent pairing with more personality. Swap it if a brand has its own face.