Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/apursley2012/sys-witch/llms.txt

Use this file to discover all available pages before exploring further.

ParticleField is a self-contained React component that paints a field of slowly drifting neon particles onto an HTML5 <canvas> element. It fills the entire viewport behind all other page content, creating the signature atmospheric glow of Sys.Witch V2 without interfering with mouse events or keyboard navigation. The animation is driven entirely by requestAnimationFrame and cleans up after itself on unmount.

Canvas Setup

The component attaches a ref to its <canvas> element and runs a single useEffect to bootstrap the animation loop. On mount it:
  1. Reads canvas.getContext("2d") to obtain the 2D drawing context.
  2. Checks window.matchMedia("(prefers-reduced-motion: reduce)") — if the user has requested reduced motion, the effect returns immediately without starting the loop.
  3. Sets canvas.width = window.innerWidth and canvas.height = window.innerHeight, then creates the initial particle array.
  4. Adds a resize event listener that re-runs the same sizing step whenever the viewport dimensions change.
  5. Starts the animation loop via requestAnimationFrame.
On unmount the cleanup function removes the resize listener and calls cancelAnimationFrame with the stored frame ID, preventing memory leaks and ghost animations.

Particle Class

Each particle is an instance of an inner Particle class defined inside the useEffect closure. Its constructor randomises all initial values against the current canvas dimensions:
class Particle {
  constructor() {
    this.x      = Math.random() * canvas.width;
    this.y      = Math.random() * canvas.height;
    this.size   = Math.random() * 2 + 0.5;          // 0.5 – 2.5 px radius
    this.speedY = Math.random() * -0.5 - 0.1;       // always drifts upward
    this.speedX = Math.random() * 0.4 - 0.2;        // slight horizontal drift
    this.color  = colors[Math.floor(Math.random() * colors.length)];
    this.opacity = Math.random() * 0.5 + 0.1;       // 0.1 – 0.6 alpha
  }

  update() {
    this.y += this.speedY;
    this.x += this.speedX;
    // Wrap to bottom when the particle leaves the top edge
    if (this.y < 0) {
      this.y = canvas.height;
      this.x = Math.random() * canvas.width;
    }
  }

  draw() {
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
    ctx.fillStyle = this.color.replace("OPACITY", this.opacity.toString());
    ctx.fill();
  }
}
When a particle drifts above the top edge of the canvas (y < 0), it wraps back to the bottom at a new random horizontal position, creating an infinite upward-drift illusion.

Neon Colors

Particles are randomly assigned one of three neon colors from an inline array. Each color uses an "OPACITY" placeholder string that is swapped for the particle’s individual opacity at draw time:
const colors = [
  "rgba(176, 38, 255, OPACITY)",   // --neon-purple  #b026ff
  "rgba(0, 243, 255, OPACITY)",    // --neon-cyan    #00f3ff
  "rgba(255, 0, 255, OPACITY)",    // --neon-magenta #ff00ff
];
These values correspond exactly to the CSS custom properties defined in main.css:
VariableHexRGBA
--neon-purple#b026ffrgba(176, 38, 255, …)
--neon-cyan#00f3ffrgba(0, 243, 255, …)
--neon-magenta#ff00ffrgba(255, 0, 255, …)

Particle Count

The number of particles is capped to avoid performance degradation on small screens:
const count = Math.min(Math.floor(window.innerWidth / 20), 80);
This formula yields roughly one particle per 20 px of viewport width, up to a hard maximum of 80 particles. At a typical 1440 px desktop width that produces 72 particles; at 375 px mobile it produces ~18.

Animation Loop

The draw function called every frame first clears the full canvas rectangle, then iterates every particle — calling update() followed by draw() — before scheduling the next frame:
const animate = () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  particles.forEach((p) => {
    p.update();
    p.draw();
  });
  frameId = requestAnimationFrame(animate);
};

CSS Classes

The canvas element uses four Tailwind utility classes:
<canvas
  ref={canvasRef}
  className="fixed inset-0 pointer-events-none z-0 opacity-60"
  aria-hidden="true"
/>
ClassPurpose
fixed inset-0Pins the canvas to all four viewport edges so it never scrolls
z-0Sits behind <main> (z-10) and <PortalNav> (z-40/z-50)
pointer-events-noneAll clicks, drags, and touch events pass straight through to page content
opacity-60Softens the particle layer to 60 % global opacity for a subtler background effect

Accessibility

The canvas carries aria-hidden="true", which hides it from the accessibility tree entirely. Screen readers will not announce it, and it carries no semantic meaning. It is purely decorative.
Motion is automatically suppressed for users with prefers-reduced-motion: reduce set in their OS or browser. The useEffect checks this media query before starting the animation loop, so no particles are rendered for users who prefer reduced motion.

Usage in Layout

ParticleField is rendered as the very first child of Layout, before PortalNav and <main>, ensuring it occupies the lowest visual layer:
// components/layout/Layout.js
import ParticleField from "./ParticleField";
import PortalNav from "./PortalNav";

const Layout = () => {
  const location = useLocation();
  const isHome = location.pathname === "/";

  return (
    <div className="min-h-screen flex flex-col relative">
      {/* Layer 0 — always rendered, sits behind everything */}
      <ParticleField />

      {/* Layer 40/50 — navigation bar (hidden on home route) */}
      {!isHome && <PortalNav />}

      {/* Layer 10 — scrollable page content */}
      <main className={`flex-grow relative z-10 ${isHome ? "" : "pt-24 pb-16"}`}>
        <Outlet />
      </main>
    </div>
  );
};

Disabling ParticleField

To remove the particle background entirely, delete the <ParticleField /> line from Layout.js. No other changes are necessary — the component has no side effects beyond the canvas and its animation loop.
If you want to keep the import but conditionally disable particles on specific routes, wrap the component in a conditional: {pathname !== "/heavy-page" && <ParticleField />}. The cleanup in useEffect ensures the animation loop stops as soon as the component unmounts.
Do not render ParticleField more than once per page. Each instance starts its own independent requestAnimationFrame loop and canvas element. Duplicate instances will overlap at z-0 and double the draw calls without any visual benefit.

Build docs developers (and LLMs) love