SVG Particle
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. |
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}
/>;Last updated on Mar 26