Nexul UI

SVG Particles

Integrate SVG particle effects into your web designs using Nexul UI components.

artsvg particlesinteractiveperformancecall to action
Open in
"use client";

import React, { useState } from "react";
import Container from "@/components/mdx/container";
import { useFPS } from "@/lib/useFPS";
import SVGParticles from "@/registry/art/svg-particles/svg-particles";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ChevronDownIcon, SettingsIcon } from "lucide-react";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";

type LogoConfig = {
  name: string;
  path: string | string[];
  viewBoxWidth: number;
  viewBoxHeight: number;
  scatteredColor: string;
  particleColor?: string;
  pathStyle?: "fill" | "stroke";
  strokeWidth?: number;
  lineJoin?: CanvasLineJoin;
  lineCap?: CanvasLineCap;
};

const LOGOS: LogoConfig[] = [
  {
    name: "AWS",
    path: "M86 66l2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z",
    viewBoxWidth: 283,
    viewBoxHeight: 150,
    scatteredColor: "#FF9900",
    particleColor: "white",
  },
  {
    name: "GitHub",
    path: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z",
    viewBoxWidth: 16,
    viewBoxHeight: 16,
    scatteredColor: "#6e5494",
    particleColor: "white",
  },
  {
    name: "React",
    path: "M418.2 177.2c-5.4-1.8-10.8-3.5-16.2-5.1.9-3.7 1.7-7.4 2.5-11.1 12.3-59.6 4.2-107.5-23.1-123.3-26.3-15.1-69.2.6-112.6 38.4-4.3 3.7-8.5 7.6-12.5 11.5-2.7-2.6-5.5-5.2-8.3-7.7-45.5-40.4-91.1-57.4-118.4-41.5-26.2 15.2-34 60.3-23 116.7 1.1 5.6 2.3 11.1 3.7 16.7-6.4 1.8-12.7 3.8-18.6 5.9C38.3 196.2 0 225.4 0 255.6c0 31.2 40.8 62.5 96.3 81.5 4.5 1.5 9 3 13.6 4.3-1.5 6-2.8 11.9-4 18-10.5 55.5-2.3 99.5 23.9 114.6 27 15.6 72.4-.4 116.6-39.1 3.5-3.1 7-6.3 10.5-9.7 4.4 4.3 9 8.4 13.6 12.4 42.8 36.8 85.1 51.7 111.2 36.6 27-15.6 35.8-62.9 24.4-120.5-.9-4.4-1.9-8.9-3-13.5 3.2-.9 6.3-1.9 9.4-2.9 57.7-19.1 99.5-50 99.5-81.7 0-30.3-39.4-59.7-93.8-78.4zM282.9 92.3c37.2-32.4 71.9-45.1 87.7-36 16.9 9.7 23.4 48.9 12.8 100.4-.7 3.4-1.4 6.7-2.3 10-22.2-5-44.7-8.6-67.3-10.6-13-18.6-27.2-36.4-42.6-53.1 3.9-3.7 7.7-7.2 11.7-10.7zM167.2 307.5c5.1 8.7 10.3 17.4 15.8 25.9-15.6-1.7-31.1-4.2-46.4-7.5 4.4-14.4 9.9-29.3 16.3-44.5 4.6 8.8 9.3 17.5 14.3 26.1zm-30.3-120.3c14.4-3.2 29.7-5.8 45.6-7.8-5.3 8.3-10.5 16.8-15.4 25.4-4.9 8.5-9.7 17.2-14.2 26-6.3-14.9-11.6-29.5-16-43.6zm27.4 68.9c6.6-13.8 13.8-27.3 21.4-40.6s15.8-26.2 24.4-38.9c15-1.1 30.3-1.7 45.9-1.7s31 .6 45.9 1.7c8.5 12.6 16.6 25.5 24.3 38.7s14.9 26.7 21.7 40.4c-6.7 13.8-13.9 27.4-21.6 40.8-7.6 13.3-15.7 26.2-24.2 39-14.9 1.1-30.4 1.6-46.1 1.6s-30.9-.5-45.6-1.4c-8.7-12.7-16.9-25.7-24.6-39s-14.8-26.8-21.5-40.6zm180.6 51.2c5.1-8.8 9.9-17.7 14.6-26.7 6.4 14.5 12 29.2 16.9 44.3-15.5 3.5-31.2 6.2-47 8 5.4-8.4 10.5-17 15.5-25.6zm14.4-76.5c-4.7-8.8-9.5-17.6-14.5-26.2-4.9-8.5-10-16.9-15.3-25.2 16.1 2 31.5 4.7 45.9 8-4.6 14.8-10 29.2-16.1 43.4zM256.2 118.3c10.5 11.4 20.4 23.4 29.6 35.8-19.8-.9-39.7-.9-59.5 0 9.8-12.9 19.9-24.9 29.9-35.8zM140.2 57c16.8-9.8 54.1 4.2 93.4 39 2.5 2.2 5 4.6 7.6 7-15.5 16.7-29.8 34.5-42.9 53.1-22.6 2-45 5.5-67.2 10.4-1.3-5.1-2.4-10.3-3.5-15.5-9.4-48.4-3.2-84.9 12.6-94zm-24.5 263.6c-4.2-1.2-8.3-2.5-12.4-3.9-21.3-6.7-45.5-17.3-63-31.2-10.1-7-16.9-17.8-18.8-29.9 0-18.3 31.6-41.7 77.2-57.6 5.7-2 11.5-3.8 17.3-5.5 6.8 21.7 15 43 24.5 63.6-9.6 20.9-17.9 42.5-24.8 64.5zm116.6 98c-16.5 15.1-35.6 27.1-56.4 35.3-11.1 5.3-23.9 5.8-35.3 1.3-15.9-9.2-22.5-44.5-13.5-92 1.1-5.6 2.3-11.2 3.7-16.7 22.4 4.8 45 8.1 67.9 9.8 13.2 18.7 27.7 36.6 43.2 53.4-3.2 3.1-6.4 6.1-9.6 8.9zm24.5-24.3c-10.2-11-20.4-23.2-30.3-36.3 9.6.4 19.5.6 29.5.6 10.3 0 20.4-.2 30.4-.7-9.2 12.7-19.1 24.8-29.6 36.4zm130.7 30c-.9 12.2-6.9 23.6-16.5 31.3-15.9 9.2-49.8-2.8-86.4-34.2-4.2-3.6-8.4-7.5-12.7-11.5 15.3-16.9 29.4-34.8 42.2-53.6 22.9-1.9 45.7-5.4 68.2-10.5 1 4.1 1.9 8.2 2.7 12.2 4.9 21.6 5.7 44.1 2.5 66.3zm18.2-107.5c-2.8.9-5.6 1.8-8.5 2.6-7-21.8-15.6-43.1-25.5-63.8 9.6-20.4 17.7-41.4 24.5-62.9 5.2 1.5 10.2 3.1 15 4.7 46.6 16 79.3 39.8 79.3 58 0 19.6-34.9 44.9-84.8 61.4zm-149.7-15c25.3 0 45.8-20.5 45.8-45.8s-20.5-45.8-45.8-45.8c-25.3 0-45.8 20.5-45.8 45.8s20.5 45.8 45.8 45.8z",
    viewBoxWidth: 512,
    viewBoxHeight: 512,
    scatteredColor: "#61DAFB",
    particleColor: "white",
  },
  {
    name: "Vercel",
    path: "M12 1L24 22H0L12 1Z",
    viewBoxWidth: 24,
    viewBoxHeight: 22,
    scatteredColor: "#00DCFF",
    particleColor: "white",
  },
  {
    name: "Next.js",
    path: ["M9 15v-6l7.745 10.65a9 9 0 1 1 2.255 -1.993", "M15 12v-3"],
    viewBoxWidth: 24,
    viewBoxHeight: 24,
    scatteredColor: "#ffffff",
    particleColor: "#888888",
    pathStyle: "stroke",
    strokeWidth: 2,
    lineJoin: "round",
    lineCap: "round",
  },
  {
    name: "TypeScript",
    path: "M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z",
    viewBoxWidth: 24,
    viewBoxHeight: 24,
    scatteredColor: "#3178C6",
    particleColor: "white",
  },
  {
    name: "Tailwind CSS",
    path: "M12 6.036c-2.667 0-4.333 1.325-5 3.976 1-1.325 2.167-1.822 3.5-1.491.761.189 1.305.738 1.906 1.345C13.387 10.855 14.522 12 17 12c2.667 0 4.333-1.325 5-3.976-1 1.325-2.166 1.822-3.5 1.491-.761-.189-1.305-.738-1.907-1.345-.98-.99-2.114-2.134-4.593-2.134zM7 12c-2.667 0-4.333 1.325-5 3.976 1-1.326 2.167-1.822 3.5-1.491.761.189 1.305.738 1.907 1.345.98.989 2.115 2.134 4.594 2.134 2.667 0 4.333-1.325 5-3.976-1 1.325-2.167 1.822-3.5 1.491-.761-.189-1.305-.738-1.906-1.345C10.613 13.145 9.478 12 7 12z",
    viewBoxWidth: 24,
    viewBoxHeight: 24,
    scatteredColor: "#38BDF8",
    particleColor: "white",
  },
  {
    name: "Node.js",
    path: "M11.998 24c-.321 0-.641-.084-.922-.247L8.14 22.016c-.438-.245-.224-.332-.08-.383.581-.203.699-.249 1.318-.603.065-.037.15-.023.217.017l2.256 1.339c.082.045.198.045.275 0l8.795-5.077c.082-.047.134-.141.134-.238V6.921c0-.099-.053-.194-.137-.242l-8.791-5.072c-.081-.047-.189-.047-.271 0L3.075 6.68c-.085.048-.139.143-.139.241v10.15c0 .097.054.189.139.235l2.409 1.392c1.307.653 2.108-.116 2.108-.89V7.787c0-.142.114-.253.256-.253h1.115c.139 0 .255.111.255.253v10.021c0 1.745-.95 2.745-2.604 2.745-.508 0-.909 0-2.026-.551L2.28 18.675c-.57-.329-.922-.943-.922-1.604V6.921c0-.661.352-1.275.922-1.603L11.076.241c.558-.317 1.303-.317 1.845 0l8.794 5.077c.57.329.924.942.924 1.603v10.15c0 .661-.354 1.273-.924 1.604l-8.794 5.078c-.28.163-.6.247-.923.247zM19.34 13.678c0-1.855-1.254-2.348-3.885-2.696-2.663-.352-2.934-.534-2.934-1.156 0-.515.229-1.202 2.2-1.202 1.76 0 2.409.38 2.677 1.568.024.103.117.178.224.178h1.145c.067 0 .131-.026.177-.078.046-.051.067-.119.058-.187-.178-2.104-1.578-3.082-4.281-3.082-2.45 0-3.91 1.034-3.91 2.767 0 1.882 1.455 2.402 3.805 2.633 2.812.277 3.012.691 3.012 1.247 0 .966-.775 1.377-2.598 1.377-2.289 0-2.793-.575-2.961-1.714-.019-.112-.116-.196-.231-.196H10.72c-.126 0-.229.103-.229.231 0 1.337.728 2.931 4.352 2.931 2.605 0 4.104-1.024 4.104-2.814l-.607.293z",
    viewBoxWidth: 24,
    viewBoxHeight: 24,
    scatteredColor: "#8CC84B",
    particleColor: "white",
  },
];

function LogoIcon({
  logo,
  className,
}: {
  logo: LogoConfig;
  className?: string;
}) {
  return (
    <svg
      viewBox={`0 0 ${logo.viewBoxWidth} ${logo.viewBoxHeight}`}
      xmlns="http://www.w3.org/2000/svg"
      className={className}
      fill={logo.pathStyle === "stroke" ? "none" : "currentColor"}
      stroke={logo.pathStyle === "stroke" ? "currentColor" : "none"}
      strokeWidth={logo.strokeWidth}
      strokeLinejoin={logo.lineJoin}
      strokeLinecap={logo.lineCap}
    >
      {Array.isArray(logo.path) ? (
        logo.path.map((d, i) => <path key={i} d={d} />)
      ) : (
        <path d={logo.path} />
      )}
    </svg>
  );
}

type Props = object;

export function Example1({}: Props) {
  const fps = useFPS();
  const [selectedLogo, setSelectedLogo] = useState<LogoConfig>(LOGOS[0]);
  const [interactionMode, setInteractionMode] = useState<"scatter" | "spill">("scatter");
  const [returnSpeed, setReturnSpeed] = useState(0.1);
  const [friction, setFriction] = useState(0.95);
  const [forceMu, setForceMu] = useState(1);
  const [enableParticleDeath, setEnableParticleDeath] = useState(false);

  return (
    <Container
      resizable
      className="h-150"
      containerProps={{ className: "block overflow-auto" }}
    >
      <div className="absolute top-0 left-0 flex items-center gap-2 py-2 z-50">
        <Button variant="outline" size="sm" className="font-semibold">
          FPS: {fps}
        </Button>

        <div className="flex-1" />

        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="outline" size="sm" className="gap-2">
              <LogoIcon logo={selectedLogo} className="size-4" />
              {selectedLogo.name}
              <ChevronDownIcon className="size-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start">
            <DropdownMenuLabel>Select Logo</DropdownMenuLabel>
            <DropdownMenuSeparator />
            {LOGOS.map((logo) => (
              <DropdownMenuItem
                key={logo.name}
                onClick={() => setSelectedLogo(logo)}
                className={
                  selectedLogo.name === logo.name ? "bg-accent" : undefined
                }
              >
                <LogoIcon logo={logo} className="size-4" />
                {logo.name}
              </DropdownMenuItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>

        <Popover>
          <PopoverTrigger asChild>
            <Button variant="outline" size="sm">
              <SettingsIcon className="size-4" />
            </Button>
          </PopoverTrigger>
          <PopoverContent align="start" className="w-72">
            <div className="space-y-4">
              <h4 className="font-medium text-sm">Interaction Settings</h4>
              
              <div className="space-y-2">
                <Label className="text-xs">Mode</Label>
                <div className="flex gap-2">
                  <Button
                    variant={interactionMode === "scatter" ? "default" : "outline"}
                    size="sm"
                    onClick={() => setInteractionMode("scatter")}
                    className="flex-1"
                  >
                    Scatter
                  </Button>
                  <Button
                    variant={interactionMode === "spill" ? "default" : "outline"}
                    size="sm"
                    onClick={() => setInteractionMode("spill")}
                    className="flex-1"
                  >
                    Spill
                  </Button>
                </div>
              </div>

              <div className="flex items-center gap-2">
                <Switch
                  id="particle-death"
                  checked={enableParticleDeath}
                  onCheckedChange={(checked) => setEnableParticleDeath(checked === true)}
                />
                <Label htmlFor="particle-death" className="text-xs cursor-pointer">
                  Enable Particle Death
                </Label>
              </div>

              <div className="space-y-2">
                <div className="flex justify-between">
                  <Label className="text-xs">Force</Label>
                  <span className="text-xs text-muted-foreground">{forceMu.toFixed(2)}</span>
                </div>
                <Slider
                  min={0.1}
                  max={3}
                  step={0.1}
                  value={[forceMu]}
                  onValueChange={([v]) => setForceMu(v)}
                />
              </div>

              <div className="space-y-2">
                <div className="flex justify-between">
                  <Label className="text-xs">Return Speed</Label>
                  <span className="text-xs text-muted-foreground">{returnSpeed.toFixed(2)}</span>
                </div>
                <Slider
                  min={0.01}
                  max={0.5}
                  step={0.01}
                  value={[returnSpeed]}
                  onValueChange={([v]) => setReturnSpeed(v)}
                />
              </div>

              {interactionMode === "spill" && (
                <div className="space-y-2">
                  <div className="flex justify-between">
                    <Label className="text-xs">Friction</Label>
                    <span className="text-xs text-muted-foreground">{friction.toFixed(2)}</span>
                  </div>
                  <Slider
                    min={0.8}
                    max={0.99}
                    step={0.01}
                    value={[friction]}
                    onValueChange={([v]) => setFriction(v)}
                  />
                </div>
              )}
            </div>
          </PopoverContent>
        </Popover>
      </div>
      <div className="w-full h-full">
        <SVGParticles
          key={selectedLogo.name}
          svgPath={selectedLogo.path}
          className="w-full h-full"
          viewBoxWidth={selectedLogo.viewBoxWidth}
          viewBoxHeight={selectedLogo.viewBoxHeight}
          logoHeight={200}
          mobileLogoHeight={100}
          scatteredColor={selectedLogo.scatteredColor}
          particleColor={selectedLogo.particleColor}
          pathStyle={selectedLogo.pathStyle}
          strokeWidth={selectedLogo.strokeWidth}
          lineJoin={selectedLogo.lineJoin}
          lineCap={selectedLogo.lineCap}
          interactionMode={interactionMode}
          returnSpeed={returnSpeed}
          friction={friction}
          forceMu={forceMu}
          enableParticleDeath={enableParticleDeath}
        />
      </div>
    </Container>
  );
}

Introduction

Creating eye-catching interactive particle effects can be challenging, especially when you want them to follow specific shapes like logos or icons. This component transforms any SVG path into a dynamic particle system that responds to mouse and touch interactions.

The SVG Particles component renders thousands of small particles that form your SVG shape, with physics-based interactions that create engaging visual effects. Perfect for hero sections, call-to-action areas, or anywhere you want to add a memorable interactive element.

Installation

CLI Installation

Make sure to have the following line in your components.json file:

{
	"registries": {
		"@nexul": "https://ui.nexul.in/r/{name}.json"
	}
}

Run the following command:

npx shadcn@latest add @nexul/svg-particles

Manual Installation

Dependencies

lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy the source file

components/ui/svg-particles.tsx
"use client";

import React, { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";

type Props = React.HTMLAttributes<HTMLDivElement> & {
  /** SVG path string(s) (d attribute) to render as particles. Can be a single path or an array of paths. */
  svgPath: string | string[];
  /** Original viewBox width of the SVG path */
  viewBoxWidth?: number;
  /** Original viewBox height of the SVG path */
  viewBoxHeight?: number;
  /** Height of the logo in pixels (desktop) */
  logoHeight?: number;
  /** Height of the logo in pixels (mobile) */
  mobileLogoHeight?: number;
  /** Color of particles when scattered by mouse interaction */
  scatteredColor?: string;
  /** Base color of particles */
  particleColor?: string;
  /** Background color of the canvas */
  backgroundColor?: string;
  /** Whether to fill or stroke the SVG path */
  pathStyle?: "fill" | "stroke";
  /** Stroke width when using stroke style (in viewBox units) */
  strokeWidth?: number;
  /** Line join style for stroke corners */
  lineJoin?: CanvasLineJoin;
  /** Line cap style for stroke endpoints */
  lineCap?: CanvasLineCap;
  /** Force multiplier for particle scattering */
  forceMu?: number;
  /** Interaction mode: "scatter" pushes particles while mouse is near, "spill" gives particles velocity that decays over time */
  interactionMode?: "scatter" | "spill";
  /** How quickly particles return to their base position (0-1, lower = slower) */
  returnSpeed?: number;
  /** Friction applied to particle velocity in spill mode (0-1, lower = more friction) */
  friction?: number;
  /** Whether to enable particle death */
  enableParticleDeath?: boolean;
};

export default function SVGParticles({
  svgPath,
  viewBoxWidth = 100,
  viewBoxHeight = 100,
  logoHeight: desktopLogoHeight = 120,
  mobileLogoHeight = 60,
  scatteredColor = "#00DCFF",
  particleColor = "white",
  backgroundColor = "black",
  pathStyle = "fill",
  strokeWidth = 1,
  lineJoin = "miter",
  lineCap = "butt",
  forceMu = 1,
  interactionMode = "scatter",
  returnSpeed = 0.1,
  friction = 0.95,
  enableParticleDeath = true,
  className,
  ...props
}: Props) {
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const mousePositionRef = useRef({ x: 0, y: 0 });
  const isTouchingRef = useRef(false);
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    const container = containerRef.current;
    const canvas = canvasRef.current;
    if (!container || !canvas) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const updateCanvasSize = () => {
      const rect = container.getBoundingClientRect();
      canvas.width = rect.width;
      canvas.height = rect.height;
      setIsMobile(rect.width < 768);
    };

    updateCanvasSize();

    let particles: {
      x: number;
      y: number;
      baseX: number;
      baseY: number;
      size: number;
      life: number;
      vx: number;
      vy: number;
    }[] = [];

    let textImageData: ImageData | null = null;

    function createTextImage() {
      if (!ctx || !canvas) return 0;

      ctx.fillStyle = particleColor;
      ctx.save();

      const logoHeight = isMobile ? mobileLogoHeight : desktopLogoHeight;
      const scale = logoHeight / viewBoxHeight;
      const logoWidth = viewBoxWidth * scale;

      ctx.translate(
        canvas.width / 2 - logoWidth / 2,
        canvas.height / 2 - logoHeight / 2
      );

      ctx.scale(scale, scale);

      // Support both single path and array of paths
      const paths = Array.isArray(svgPath) ? svgPath : [svgPath];
      for (const pathStr of paths) {
        const path = new Path2D(pathStr);
        if (pathStyle === "stroke") {
          ctx.strokeStyle = particleColor;
          ctx.lineWidth = strokeWidth;
          ctx.lineJoin = lineJoin;
          ctx.lineCap = lineCap;
          ctx.stroke(path);
        } else {
          ctx.fill(path);
        }
      }

      ctx.restore();

      // Guard against zero-size canvas (can happen during unmount)
      if (canvas.width === 0 || canvas.height === 0) {
        return scale;
      }

      textImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      return scale;
    }

    function createParticle(scale: number) {
      if (!ctx || !canvas || !textImageData) return null;

      const data = textImageData.data;

      for (let attempt = 0; attempt < 100; attempt++) {
        const x = Math.floor(Math.random() * canvas.width);
        const y = Math.floor(Math.random() * canvas.height);

        if (data[(y * canvas.width + x) * 4 + 3] > 128) {
          return {
            x: x,
            y: y,
            baseX: x,
            baseY: y,
            size: Math.random() * 1 + 0.5,
            life: Math.random() * 100 + 50,
            vx: 0,
            vy: 0,
          };
        }
      }

      return null;
    }

    function createInitialParticles(scale: number) {
      if (!ctx || !canvas) return;

      const baseParticleCount = 7000;
      const particleCount = Math.floor(
        baseParticleCount *
          Math.sqrt((canvas.width * canvas.height) / (1920 * 1080))
      );
      for (let i = 0; i < particleCount; i++) {
        const particle = createParticle(scale);
        if (particle) particles.push(particle);
      }
    }

    let animationFrameId: number;

    function animate(scale: number) {
      if (!ctx || !canvas) return;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.fillStyle = backgroundColor;
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      const { x: mouseX, y: mouseY } = mousePositionRef.current;
      const maxDistance = 240;

      for (let i = 0; i < particles.length; i++) {
        const p = particles[i];
        const dx = mouseX - p.x;
        const dy = mouseY - p.y;
        const distance = Math.sqrt(dx * dx + dy * dy);

        const isInteracting =
          distance < maxDistance &&
          (isTouchingRef.current || !("ontouchstart" in window));

        if (interactionMode === "spill") {
          // Spill mode: add velocity when mouse is near, particles drift and return slowly
          if (isInteracting) {
            const force = (forceMu * (maxDistance - distance)) / maxDistance;
            const angle = Math.atan2(dy, dx);
            p.vx -= Math.cos(angle) * force * 2;
            p.vy -= Math.sin(angle) * force * 2;
          }

          // Apply velocity
          p.x += p.vx;
          p.y += p.vy;

          // Apply friction to slow down
          p.vx *= friction;
          p.vy *= friction;

          // Slowly return to base position
          const returnDx = p.baseX - p.x;
          const returnDy = p.baseY - p.y;
          p.vx += returnDx * returnSpeed * 0.1;
          p.vy += returnDy * returnSpeed * 0.1;

          // Color based on distance from base position
          const distFromBase = Math.sqrt(
            returnDx * returnDx + returnDy * returnDy
          );
          if (distFromBase > 2) {
            ctx.fillStyle = scatteredColor;
          } else {
            ctx.fillStyle = particleColor;
          }
        } else {
          // Scatter mode: particles move away while mouse is near, snap back when it leaves
          if (isInteracting) {
            const force = (forceMu * (maxDistance - distance)) / maxDistance;
            const angle = Math.atan2(dy, dx);
            const moveX = Math.cos(angle) * force * 60;
            const moveY = Math.sin(angle) * force * 60;
            p.x = p.baseX - moveX;
            p.y = p.baseY - moveY;

            ctx.fillStyle = scatteredColor;
          } else {
            p.x += (p.baseX - p.x) * returnSpeed;
            p.y += (p.baseY - p.y) * returnSpeed;
            ctx.fillStyle = particleColor;
          }
        }

        ctx.fillRect(p.x, p.y, p.size, p.size);

        if (enableParticleDeath) p.life--;
        if (p.life <= 0) {
          const newParticle = createParticle(scale);
          if (newParticle) {
            particles[i] = newParticle;
          } else {
            particles.splice(i, 1);
            i--;
          }
        }
      }

      const baseParticleCount = 7000;
      const targetParticleCount = Math.floor(
        baseParticleCount *
          Math.sqrt((canvas.width * canvas.height) / (1920 * 1080))
      );
      while (particles.length < targetParticleCount) {
        const newParticle = createParticle(scale);
        if (newParticle) particles.push(newParticle);
      }

      animationFrameId = requestAnimationFrame(() => animate(scale));
    }

    const scale = createTextImage();
    createInitialParticles(scale);
    animate(scale);

    const handleResize = () => {
      updateCanvasSize();
      const newScale = createTextImage();
      particles = [];
      createInitialParticles(newScale);
    };

    const resizeObserver = new ResizeObserver(handleResize);
    resizeObserver.observe(container);

    const handleMove = (x: number, y: number) => {
      const rect = canvas.getBoundingClientRect();
      mousePositionRef.current = { x: x - rect.left, y: y - rect.top };
    };

    const handleMouseMove = (e: MouseEvent) => {
      handleMove(e.clientX, e.clientY);
    };

    const handleTouchMove = (e: TouchEvent) => {
      if (e.touches.length > 0) {
        e.preventDefault();
        handleMove(e.touches[0].clientX, e.touches[0].clientY);
      }
    };

    const handleTouchStart = () => {
      isTouchingRef.current = true;
    };

    const handleTouchEnd = () => {
      isTouchingRef.current = false;
      mousePositionRef.current = { x: 0, y: 0 };
    };

    const handleMouseLeave = () => {
      if (!("ontouchstart" in window)) {
        mousePositionRef.current = { x: 0, y: 0 };
      }
    };

    canvas.addEventListener("mousemove", handleMouseMove);
    canvas.addEventListener("touchmove", handleTouchMove, { passive: false });
    canvas.addEventListener("mouseleave", handleMouseLeave);
    canvas.addEventListener("touchstart", handleTouchStart);
    canvas.addEventListener("touchend", handleTouchEnd);

    return () => {
      resizeObserver.disconnect();
      canvas.removeEventListener("mousemove", handleMouseMove);
      canvas.removeEventListener("touchmove", handleTouchMove);
      canvas.removeEventListener("mouseleave", handleMouseLeave);
      canvas.removeEventListener("touchstart", handleTouchStart);
      canvas.removeEventListener("touchend", handleTouchEnd);
      cancelAnimationFrame(animationFrameId);
    };
  }, [
    svgPath,
    isMobile,
    viewBoxWidth,
    viewBoxHeight,
    desktopLogoHeight,
    mobileLogoHeight,
    scatteredColor,
    particleColor,
    backgroundColor,
    pathStyle,
    strokeWidth,
    lineJoin,
    lineCap,
    forceMu,
    interactionMode,
    returnSpeed,
    friction,
    enableParticleDeath,
  ]);

  return (
    <div ref={containerRef} className={cn("relative", className)} {...props}>
      <canvas
        ref={canvasRef}
        className="w-full h-full absolute top-0 left-0 touch-none"
        aria-label="Interactive particle effect"
      />
    </div>
  );
}

Usage

page.tsx
import SVGParticles from "@/components/ui/svg-particles";

// Simple usage with a single SVG path
export default function Page() {
  return (
    <SVGParticles
      svgPath="M12 1L24 22H0L12 1Z"
      viewBoxWidth={24}
      viewBoxHeight={22}
      logoHeight={200}
      scatteredColor="#00DCFF"
      particleColor="white"
      className="w-full h-screen"
    />
  );
}

Multiple Paths

You can pass an array of paths to render complex SVGs with multiple path elements:

page.tsx
<SVGParticles
  svgPath={["M9 15v-6l7.745 10.65a9 9 0 1 1 2.255 -1.993", "M15 12v-3"]}
  viewBoxWidth={24}
  viewBoxHeight={24}
  pathStyle="stroke"
  strokeWidth={2}
  lineJoin="round"
  lineCap="round"
/>

Stroke Mode

For SVGs that use strokes instead of fills (like icon outlines), use the stroke-related props:

page.tsx
<SVGParticles
  svgPath="M9 15v-6l7.745 10.65a9 9 0 1 1 2.255 -1.993"
  pathStyle="stroke"
  strokeWidth={2}
  lineJoin="round"
  lineCap="round"
/>

Interaction Modes

The SVGParticles component supports two interaction modes: scatter and spill.

Use scatter mode for a snappy, responsive feel where particles immediately follow the mouse.

Use spill mode for a more fluid, physics-based interaction where particles have momentum and drift.

Scatter Mode

In scatter mode, particles are pushed away from the cursor while it's nearby and immediately return to their original positions when the cursor moves away. This creates a responsive, magnetic-repulsion effect.

<SVGParticles
  svgPath={yourPath}
  interactionMode="scatter"
  forceMu={1}        // How strongly particles are pushed
  returnSpeed={0.1}  // How quickly they return (0-1)
/>

Spill Mode

In spill mode, particles gain velocity when pushed and continue moving with momentum, gradually slowing down due to friction. This creates a more fluid, liquid-like effect.

<SVGParticles
  svgPath={yourPath}
  interactionMode="spill"
  forceMu={1}        // How strongly particles are pushed
  returnSpeed={0.1}  // How quickly they drift back
  friction={0.95}    // Velocity decay (0.8-0.99, higher = less friction)
/>

Particle Lifecycle

When enableParticleDeath is set to true, particles have a limited lifespan and will fade out and respawn. This creates a subtle shimmer effect and keeps the animation dynamic.

<SVGParticles
  svgPath={yourPath}
  enableParticleDeath={true}  // Particles will die and respawn
/>

Set to false for a static particle field that only responds to interactions.

Props Reference

Prop

Type

Finding SVG Paths

To use this component, you need the d attribute from an SVG <path> element. Here's how to find it:

  1. From SVG files: Open the SVG in a text editor and look for <path d="..."> elements
  2. From icon libraries: Most icon libraries provide the raw SVG path data
  3. From design tools: Export your design as SVG and extract the path data

Tips for Best Results

  • Solid shapes work best: Filled paths create denser particle fields than thin strokes
  • Simple paths: Complex paths with many curves may require more particles to look good
  • Stroke width: When using pathStyle="stroke", increase strokeWidth for better visibility
  • Scaling: Adjust logoHeight and mobileLogoHeight to fit your design