SVG Particle Effect for React
Three.js particle field from SVG source. Pass icon components from lucide-react, react-icons, Heroicons, Tabler, Phosphor, or any <svg /> - works out of the box. Particles react to the cursor and spring home.
Component
"use client";
import { Canvas, useFrame } from "@react-three/fiber";
import {
isValidElement,
type PointerEvent,
type ReactElement,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { renderToStaticMarkup } from "react-dom/server";
import * as THREE from "three";
import { cn } from "@/lib/utils";
interface SvgParticleProps {
className?: string;
svg: ReactElement | string;
width?: number;
height?: number;
color?: string;
particleGap?: number;
particleSize?: number;
maxParticles?: number;
repelRadius?: number;
repelStrength?: number;
returnStrength?: number;
damping?: number;
}
interface MouseState {
active: boolean;
x: number;
y: number;
}
interface ParticleFieldProps {
homePositions: Float32Array;
mouseRef: RefObject<MouseState>;
color: string;
particleSize: number;
repelRadius: number;
repelStrength: number;
returnStrength: number;
damping: number;
}
const VERTEX_SHADER = `
uniform float uPointSize;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = uPointSize;
gl_Position = projectionMatrix * mvPosition;
}
`;
const FRAGMENT_SHADER = `
uniform vec3 uColor;
void main() {
vec2 uv = gl_PointCoord - vec2(0.5);
float dist = length(uv);
float alpha = smoothstep(0.5, 0.0, dist);
alpha *= alpha;
gl_FragColor = vec4(uColor, alpha);
}
`;
function resolveSvgToString(
svg: ReactElement | string,
): string {
const raw = isValidElement(svg)
? renderToStaticMarkup(svg)
: svg;
return raw.replace(/currentColor/g, "white");
}
function createSvgDataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
async function sampleSvgToParticles({
svg,
width,
height,
particleGap,
maxParticles,
}: {
svg: string;
width: number;
height: number;
particleGap: number;
maxParticles: number;
}): Promise<Float32Array> {
const image = await new Promise<HTMLImageElement>(
(resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () =>
reject(new Error("Failed to load SVG source"));
img.src = createSvgDataUrl(svg);
},
);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
return new Float32Array();
}
context.clearRect(0, 0, width, height);
const maxDrawWidth = width * 0.78;
const maxDrawHeight = height * 0.78;
const imageRatio = image.width / image.height;
const targetRatio = maxDrawWidth / maxDrawHeight;
const drawWidth =
imageRatio > targetRatio
? maxDrawWidth
: maxDrawHeight * imageRatio;
const drawHeight =
imageRatio > targetRatio
? maxDrawWidth / imageRatio
: maxDrawHeight;
const drawX = (width - drawWidth) * 0.5;
const drawY = (height - drawHeight) * 0.5;
context.drawImage(
image,
drawX,
drawY,
drawWidth,
drawHeight,
);
const pixels = context.getImageData(
0,
0,
width,
height,
).data;
const points: number[] = [];
for (let y = 0; y < height; y += particleGap) {
for (let x = 0; x < width; x += particleGap) {
const alpha = pixels[(y * width + x) * 4 + 3] ?? 0;
if (alpha < 40) {
continue;
}
points.push(x - width * 0.5, height * 0.5 - y, 0);
}
}
if (points.length === 0) {
return new Float32Array();
}
const totalParticles = Math.floor(points.length / 3);
if (totalParticles <= maxParticles) {
return new Float32Array(points);
}
const step = Math.ceil(totalParticles / maxParticles);
const reduced: number[] = [];
for (let i = 0; i < totalParticles; i += step) {
const base = i * 3;
reduced.push(
points[base] ?? 0,
points[base + 1] ?? 0,
points[base + 2] ?? 0,
);
}
return new Float32Array(reduced);
}
function ParticleField({
homePositions,
mouseRef,
color,
particleSize,
repelRadius,
repelStrength,
returnStrength,
damping,
}: ParticleFieldProps) {
const pointsRef =
useRef<
THREE.Points<
THREE.BufferGeometry,
THREE.ShaderMaterial
>
>(null);
const particleState = useMemo(() => {
const homes = new Float32Array(homePositions);
const positions = new Float32Array(homePositions);
const velocities = new Float32Array(
homePositions.length,
);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
);
return { geometry, homes, positions, velocities };
}, [homePositions]);
const material = useMemo(
() =>
new THREE.ShaderMaterial({
blending: THREE.AdditiveBlending,
depthWrite: false,
fragmentShader: FRAGMENT_SHADER,
transparent: true,
uniforms: {
uColor: { value: new THREE.Color(color) },
uPointSize: { value: particleSize },
},
vertexShader: VERTEX_SHADER,
}),
[color, particleSize],
);
useEffect(() => {
return () => {
particleState.geometry.dispose();
material.dispose();
};
}, [material, particleState.geometry]);
useFrame((_, delta) => {
const points = pointsRef.current;
if (!points) {
return;
}
const {
active,
x: mouseX,
y: mouseY,
} = mouseRef.current;
const radiusSquared = repelRadius * repelRadius;
const speed = Math.min(delta * 60, 2.5);
for (
let i = 0;
i < particleState.positions.length;
i += 3
) {
const px = particleState.positions[i] ?? 0;
const py = particleState.positions[i + 1] ?? 0;
const hx = particleState.homes[i] ?? 0;
const hy = particleState.homes[i + 1] ?? 0;
let vx = particleState.velocities[i] ?? 0;
let vy = particleState.velocities[i + 1] ?? 0;
if (active) {
const dxMouse = px - mouseX;
const dyMouse = py - mouseY;
const distanceSquared =
dxMouse * dxMouse + dyMouse * dyMouse;
if (
distanceSquared < radiusSquared &&
distanceSquared > 0.0001
) {
const distance = Math.sqrt(distanceSquared);
const influence = 1 - distance / repelRadius;
const force = influence * repelStrength * speed;
vx += (dxMouse / distance) * force;
vy += (dyMouse / distance) * force;
}
}
vx += (hx - px) * returnStrength * speed;
vy += (hy - py) * returnStrength * speed;
vx *= damping;
vy *= damping;
particleState.velocities[i] = vx;
particleState.velocities[i + 1] = vy;
particleState.positions[i] = px + vx;
particleState.positions[i + 1] = py + vy;
}
const positionAttribute = points.geometry.getAttribute(
"position",
) as THREE.BufferAttribute;
positionAttribute.needsUpdate = true;
});
return (
<points
ref={pointsRef}
geometry={particleState.geometry}
material={material}
frustumCulled={false}
/>
);
}
export function SvgParticle({
className,
svg,
width = 400,
height = 400,
color = "#d4c6ff",
particleGap = 4,
particleSize = 3.4,
maxParticles = 4200,
repelRadius = 52,
repelStrength = 0.28,
returnStrength = 0.04,
damping = 0.9,
}: SvgParticleProps) {
const mouseRef = useRef<MouseState>({
active: false,
x: 0,
y: 0,
});
const [homePositions, setHomePositions] =
useState<Float32Array>(() => new Float32Array());
const svgString = useMemo(
() => resolveSvgToString(svg),
[svg],
);
useEffect(() => {
let isCancelled = false;
const buildParticles = async () => {
try {
const sampled = await sampleSvgToParticles({
height,
maxParticles: Math.max(
200,
Math.floor(maxParticles),
),
particleGap: Math.max(2, Math.floor(particleGap)),
svg: svgString,
width,
});
if (!isCancelled) {
setHomePositions(sampled);
}
} catch {
if (!isCancelled) {
setHomePositions(new Float32Array());
}
}
};
buildParticles().catch(() => undefined);
return () => {
isCancelled = true;
};
}, [height, maxParticles, particleGap, svgString, width]);
const handlePointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
const rect =
event.currentTarget.getBoundingClientRect();
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
mouseRef.current.active = true;
mouseRef.current.x = localX - width * 0.5;
mouseRef.current.y = height * 0.5 - localY;
},
[height, width],
);
const handlePointerLeave = useCallback(() => {
mouseRef.current.active = false;
}, []);
return (
<div
className={cn(
"relative overflow-hidden rounded-2xl",
className,
)}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
style={{ height, width }}
>
<Canvas
orthographic={true}
dpr={[1, 2]}
gl={{
alpha: true,
antialias: true,
powerPreference: "high-performance",
}}
camera={{
bottom: -height * 0.5,
far: 1000,
left: -width * 0.5,
near: 0.1,
position: [0, 0, 100],
right: width * 0.5,
top: height * 0.5,
zoom: 1,
}}
>
{homePositions.length > 0 && (
<ParticleField
homePositions={homePositions}
mouseRef={mouseRef}
color={color}
particleSize={particleSize}
repelRadius={repelRadius}
repelStrength={repelStrength}
returnStrength={returnStrength}
damping={damping}
/>
)}
</Canvas>
</div>
);
}Installation
1. Install dependencies
pnpm add three @react-three/fiber2. Copy the component file
"use client";
import { Canvas, useFrame } from "@react-three/fiber";
import {
isValidElement,
type PointerEvent,
type ReactElement,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { renderToStaticMarkup } from "react-dom/server";
import * as THREE from "three";
import { cn } from "@/lib/utils";
interface SvgParticleProps {
className?: string;
svg: ReactElement | string;
width?: number;
height?: number;
color?: string;
particleGap?: number;
particleSize?: number;
maxParticles?: number;
repelRadius?: number;
repelStrength?: number;
returnStrength?: number;
damping?: number;
}
interface MouseState {
active: boolean;
x: number;
y: number;
}
interface ParticleFieldProps {
homePositions: Float32Array;
mouseRef: RefObject<MouseState>;
color: string;
particleSize: number;
repelRadius: number;
repelStrength: number;
returnStrength: number;
damping: number;
}
const VERTEX_SHADER = `
uniform float uPointSize;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = uPointSize;
gl_Position = projectionMatrix * mvPosition;
}
`;
const FRAGMENT_SHADER = `
uniform vec3 uColor;
void main() {
vec2 uv = gl_PointCoord - vec2(0.5);
float dist = length(uv);
float alpha = smoothstep(0.5, 0.0, dist);
alpha *= alpha;
gl_FragColor = vec4(uColor, alpha);
}
`;
function resolveSvgToString(
svg: ReactElement | string,
): string {
const raw = isValidElement(svg)
? renderToStaticMarkup(svg)
: svg;
return raw.replace(/currentColor/g, "white");
}
function createSvgDataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
async function sampleSvgToParticles({
svg,
width,
height,
particleGap,
maxParticles,
}: {
svg: string;
width: number;
height: number;
particleGap: number;
maxParticles: number;
}): Promise<Float32Array> {
const image = await new Promise<HTMLImageElement>(
(resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () =>
reject(new Error("Failed to load SVG source"));
img.src = createSvgDataUrl(svg);
},
);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
return new Float32Array();
}
context.clearRect(0, 0, width, height);
const maxDrawWidth = width * 0.78;
const maxDrawHeight = height * 0.78;
const imageRatio = image.width / image.height;
const targetRatio = maxDrawWidth / maxDrawHeight;
const drawWidth =
imageRatio > targetRatio
? maxDrawWidth
: maxDrawHeight * imageRatio;
const drawHeight =
imageRatio > targetRatio
? maxDrawWidth / imageRatio
: maxDrawHeight;
const drawX = (width - drawWidth) * 0.5;
const drawY = (height - drawHeight) * 0.5;
context.drawImage(
image,
drawX,
drawY,
drawWidth,
drawHeight,
);
const pixels = context.getImageData(
0,
0,
width,
height,
).data;
const points: number[] = [];
for (let y = 0; y < height; y += particleGap) {
for (let x = 0; x < width; x += particleGap) {
const alpha = pixels[(y * width + x) * 4 + 3] ?? 0;
if (alpha < 40) {
continue;
}
points.push(x - width * 0.5, height * 0.5 - y, 0);
}
}
if (points.length === 0) {
return new Float32Array();
}
const totalParticles = Math.floor(points.length / 3);
if (totalParticles <= maxParticles) {
return new Float32Array(points);
}
const step = Math.ceil(totalParticles / maxParticles);
const reduced: number[] = [];
for (let i = 0; i < totalParticles; i += step) {
const base = i * 3;
reduced.push(
points[base] ?? 0,
points[base + 1] ?? 0,
points[base + 2] ?? 0,
);
}
return new Float32Array(reduced);
}
function ParticleField({
homePositions,
mouseRef,
color,
particleSize,
repelRadius,
repelStrength,
returnStrength,
damping,
}: ParticleFieldProps) {
const pointsRef =
useRef<
THREE.Points<
THREE.BufferGeometry,
THREE.ShaderMaterial
>
>(null);
const particleState = useMemo(() => {
const homes = new Float32Array(homePositions);
const positions = new Float32Array(homePositions);
const velocities = new Float32Array(
homePositions.length,
);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
);
return { geometry, homes, positions, velocities };
}, [homePositions]);
const material = useMemo(
() =>
new THREE.ShaderMaterial({
blending: THREE.AdditiveBlending,
depthWrite: false,
fragmentShader: FRAGMENT_SHADER,
transparent: true,
uniforms: {
uColor: { value: new THREE.Color(color) },
uPointSize: { value: particleSize },
},
vertexShader: VERTEX_SHADER,
}),
[color, particleSize],
);
useEffect(() => {
return () => {
particleState.geometry.dispose();
material.dispose();
};
}, [material, particleState.geometry]);
useFrame((_, delta) => {
const points = pointsRef.current;
if (!points) {
return;
}
const {
active,
x: mouseX,
y: mouseY,
} = mouseRef.current;
const radiusSquared = repelRadius * repelRadius;
const speed = Math.min(delta * 60, 2.5);
for (
let i = 0;
i < particleState.positions.length;
i += 3
) {
const px = particleState.positions[i] ?? 0;
const py = particleState.positions[i + 1] ?? 0;
const hx = particleState.homes[i] ?? 0;
const hy = particleState.homes[i + 1] ?? 0;
let vx = particleState.velocities[i] ?? 0;
let vy = particleState.velocities[i + 1] ?? 0;
if (active) {
const dxMouse = px - mouseX;
const dyMouse = py - mouseY;
const distanceSquared =
dxMouse * dxMouse + dyMouse * dyMouse;
if (
distanceSquared < radiusSquared &&
distanceSquared > 0.0001
) {
const distance = Math.sqrt(distanceSquared);
const influence = 1 - distance / repelRadius;
const force = influence * repelStrength * speed;
vx += (dxMouse / distance) * force;
vy += (dyMouse / distance) * force;
}
}
vx += (hx - px) * returnStrength * speed;
vy += (hy - py) * returnStrength * speed;
vx *= damping;
vy *= damping;
particleState.velocities[i] = vx;
particleState.velocities[i + 1] = vy;
particleState.positions[i] = px + vx;
particleState.positions[i + 1] = py + vy;
}
const positionAttribute = points.geometry.getAttribute(
"position",
) as THREE.BufferAttribute;
positionAttribute.needsUpdate = true;
});
return (
<points
ref={pointsRef}
geometry={particleState.geometry}
material={material}
frustumCulled={false}
/>
);
}
export function SvgParticle({
className,
svg,
width = 400,
height = 400,
color = "#d4c6ff",
particleGap = 4,
particleSize = 3.4,
maxParticles = 4200,
repelRadius = 52,
repelStrength = 0.28,
returnStrength = 0.04,
damping = 0.9,
}: SvgParticleProps) {
const mouseRef = useRef<MouseState>({
active: false,
x: 0,
y: 0,
});
const [homePositions, setHomePositions] =
useState<Float32Array>(() => new Float32Array());
const svgString = useMemo(
() => resolveSvgToString(svg),
[svg],
);
useEffect(() => {
let isCancelled = false;
const buildParticles = async () => {
try {
const sampled = await sampleSvgToParticles({
height,
maxParticles: Math.max(
200,
Math.floor(maxParticles),
),
particleGap: Math.max(2, Math.floor(particleGap)),
svg: svgString,
width,
});
if (!isCancelled) {
setHomePositions(sampled);
}
} catch {
if (!isCancelled) {
setHomePositions(new Float32Array());
}
}
};
buildParticles().catch(() => undefined);
return () => {
isCancelled = true;
};
}, [height, maxParticles, particleGap, svgString, width]);
const handlePointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
const rect =
event.currentTarget.getBoundingClientRect();
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
mouseRef.current.active = true;
mouseRef.current.x = localX - width * 0.5;
mouseRef.current.y = height * 0.5 - localY;
},
[height, width],
);
const handlePointerLeave = useCallback(() => {
mouseRef.current.active = false;
}, []);
return (
<div
className={cn(
"relative overflow-hidden rounded-2xl",
className,
)}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
style={{ height, width }}
>
<Canvas
orthographic={true}
dpr={[1, 2]}
gl={{
alpha: true,
antialias: true,
powerPreference: "high-performance",
}}
camera={{
bottom: -height * 0.5,
far: 1000,
left: -width * 0.5,
near: 0.1,
position: [0, 0, 100],
right: width * 0.5,
top: height * 0.5,
zoom: 1,
}}
>
{homePositions.length > 0 && (
<ParticleField
homePositions={homePositions}
mouseRef={mouseRef}
color={color}
particleSize={particleSize}
repelRadius={repelRadius}
repelStrength={repelStrength}
returnStrength={returnStrength}
damping={damping}
/>
)}
</Canvas>
</div>
);
}3. Import and render
import { Rocket } from "lucide-react";
import { SvgParticle } from "@/components/svg-particle";
<SvgParticle svg={<Rocket size={240} fill="white" />} />;Usage
Import
Import the component into your page or section.
import { SvgParticle } from "@/components/svg-particle";Choose an icon
Import from your usual SVG icon package - no extra setup; pass the component as svg={...}.
import { Rocket } from "lucide-react";
// or: FaGithub from "react-icons/fa", Heroicons, @tabler/icons-react, etc.Render
Pass the icon element directly as the svg prop.
<SvgParticle svg={<Rocket size={240} fill="white" />} />;Guidelines
- Icon libraries work out of the box: pass the same JSX you would render elsewhere (lucide-react, react-icons, @heroicons/react, @tabler/icons-react, phosphor-react, radix icons, etc.). No special wrappers or SVG string extraction required.
- The svg prop accepts a React element (<Rocket />) or a raw SVG string. Elements are serialized with renderToStaticMarkup before raster sampling.
- currentColor in the serialized markup is replaced with white so library defaults still produce alpha for particle placement.
- For stroke-only icons, pass fill="white" (or explicit stroke/fill colors) when you want a filled silhouette instead of a thin outline.
- The SVG is never shown as a visible layer; it is only used as geometry for particle home positions.
- Use particleGap and maxParticles to balance density and performance; tune repel and return props for interaction feel.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| svgrequired | ReactElement | string | — | SVG source for particles: a React element from any SVG icon library (lucide-react, react-icons, Heroicons, Tabler, Phosphor, custom <svg />) or a raw SVG string. |
| width | number | 400 | Canvas width in pixels. |
| height | number | 400 | Canvas height in pixels. |
| color | string | "#d4c6ff" | Particle glow color. |
| particleGap | number | 4 | Sampling gap in pixels when extracting points from the SVG. |
| particleSize | number | 3.4 | Rendered particle size. |
| maxParticles | number | 4200 | Upper bound for particle count after SVG sampling. |
| repelRadius | number | 52 | Radius around cursor where particles are displaced. |
| repelStrength | number | 0.28 | Strength of cursor displacement force. |
| returnStrength | number | 0.04 | Spring strength pulling particles back to home positions. |
| damping | number | 0.9 | Velocity damping applied each frame. |
| className | string | — | Additional classes for the outer wrapper. |
Accessibility
- Decorative WebGL visual effect with no interactive controls, buttons, links, or focusable elements, so it is invisible to keyboard and tab navigation.
- Interaction is pointer-only via onPointerMove and onPointerLeave with no keyboard or touch-focus equivalent, so cursor repulsion is unavailable to keyboard users.
- No ARIA roles, labels, or alt text are set on the canvas wrapper, so add an aria-hidden attribute or a descriptive label depending on whether the effect is purely ornamental or conveys meaning.
- The component does not check prefers-reduced-motion, so the particle simulation should be guarded with a matchMedia('(prefers-reduced-motion: reduce)') query to pause or render a static frame for users who request reduced motion.
- Because the silhouette comes from an SVG sampled only as particle geometry, any meaning carried by the source icon is not exposed to assistive technology and should be conveyed in adjacent text.
Performance
- Motion is expressed by mutating a Three.js BufferGeometry position attribute and uploading it each frame rather than animating layout properties like top, left, width, or height, so it never triggers browser layout or paint.
- Rendering uses a single GPU points draw call with a custom shader, additive blending, and depthWrite disabled, keeping it on the compositor, while dpr is capped at [1, 2] and powerPreference is set to high-performance.
- The per-frame physics loop in useFrame iterates every particle on the CPU in JavaScript, so cost scales with particle count and should be bounded with particleGap and maxParticles to avoid main-thread jank.
- Animation runs on the requestAnimationFrame loop provided by react-three-fiber with no IntersectionObserver or visibility gating, so it keeps simulating even when off-screen and could be paused when not visible.
- Cleanup is handled by disposing the geometry and material on unmount and using an isCancelled flag for the async SVG sampling effect, preventing leaked WebGL resources and stale state updates.
Examples
Lucide - Rocket
Lucide Rocket icon passed as a React element with default particle settings.
import { Rocket } from "lucide-react";
import { SvgParticle } from "@/components/svg-particle";
<SvgParticle svg={<Rocket size={240} fill="white" />} />;react-icons - React logo
React logo from react-icons with denser particles and a stronger displacement feel.
import { FaReact } from "react-icons/fa";
import { SvgParticle } from "@/components/svg-particle";
<SvgParticle
svg={<FaReact size={240} />}
color="#93c5fd"
particleGap={3}
particleSize={3.8}
repelRadius={60}
repelStrength={0.35}
/>;Lucide - Heart
Lucide Heart with warm color and snappy return spring for a bouncy feel.
import { Heart } from "lucide-react";
import { SvgParticle } from "@/components/svg-particle";
<SvgParticle
svg={<Heart size={240} fill="white" />}
color="#fca5a5"
maxParticles={2500}
returnStrength={0.055}
damping={0.88}
/>;react-icons - GitHub
GitHub Octocat from react-icons with fine-grained green particles.
import { FaGithub } from "react-icons/fa";
import { SvgParticle } from "@/components/svg-particle";
<SvgParticle
svg={<FaGithub size={240} />}
color="#86efac"
particleGap={3}
particleSize={2.8}
repelRadius={45}
/>;Related reading
- Choreographing Multi-Step Motion — Animating many particles with keyframes
- Offloading Motion to the GPU — Rendering particles without jank
Last updated on Jun 19