Background
Particles
Interactive particles component with mouse tracking and customizable animations using HTML5 Canvas.
Loading component...
import { Particles } from "@/components/ui/shadcn-io/particles";export default function ParticlesBasic() { return ( <div className="relative flex h-[500px] w-full flex-col items-center justify-center overflow-hidden rounded-lg border bg-slate-900 dark:bg-slate-950 md:shadow-xl"> <span className="pointer-events-none whitespace-pre-wrap bg-gradient-to-b from-white to-gray-300 bg-clip-text text-center text-8xl font-semibold leading-none text-transparent"> Particles </span> <Particles className="absolute inset-0" quantity={100} ease={80} color="#ffffff" refresh /> </div> );}
'use client';import React from "react";import { useEffect, useRef, useState } from "react";import { cn } from "@/lib/utils";interface MousePosition { x: number; y: number;}function useMousePosition(): MousePosition { const [mousePosition, setMousePosition] = useState<MousePosition>({ x: 0, y: 0, }); useEffect(() => { const handleMouseMove = (event: MouseEvent) => { setMousePosition({ x: event.clientX, y: event.clientY }); }; window.addEventListener("mousemove", handleMouseMove); return () => { window.removeEventListener("mousemove", handleMouseMove); }; }, []); return mousePosition;}export interface ParticlesProps { className?: string; quantity?: number; staticity?: number; ease?: number; size?: number; refresh?: boolean; color?: string; vx?: number; vy?: number;}function hexToRgb(hex: string): number[] { hex = hex.replace("#", ""); if (hex.length === 3) { hex = hex .split("") .map((char) => char + char) .join(""); } const hexInt = parseInt(hex, 16); const red = (hexInt >> 16) & 255; const green = (hexInt >> 8) & 255; const blue = hexInt & 255; return [red, green, blue];}export const Particles: React.FC<ParticlesProps> = ({ className = "", quantity = 100, staticity = 50, ease = 50, size = 0.4, refresh = false, color = "#ffffff", vx = 0, vy = 0,}) => { const canvasRef = useRef<HTMLCanvasElement>(null); const canvasContainerRef = useRef<HTMLDivElement>(null); const context = useRef<CanvasRenderingContext2D | null>(null); const circles = useRef<Circle[]>([]); const mousePosition = useMousePosition(); const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1; useEffect(() => { if (canvasRef.current) { context.current = canvasRef.current.getContext("2d"); } initCanvas(); animate(); window.addEventListener("resize", initCanvas); return () => { window.removeEventListener("resize", initCanvas); }; }, [color]); useEffect(() => { onMouseMove(); }, [mousePosition.x, mousePosition.y]); useEffect(() => { initCanvas(); }, [refresh]); const initCanvas = () => { resizeCanvas(); drawParticles(); }; const onMouseMove = () => { if (canvasRef.current) { const rect = canvasRef.current.getBoundingClientRect(); const { w, h } = canvasSize.current; const x = mousePosition.x - rect.left - w / 2; const y = mousePosition.y - rect.top - h / 2; const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2; if (inside) { mouse.current.x = x; mouse.current.y = y; } } }; type Circle = { x: number; y: number; translateX: number; translateY: number; size: number; alpha: number; targetAlpha: number; dx: number; dy: number; magnetism: number; }; const resizeCanvas = () => { if (canvasContainerRef.current && canvasRef.current && context.current) { circles.current.length = 0; canvasSize.current.w = canvasContainerRef.current.offsetWidth; canvasSize.current.h = canvasContainerRef.current.offsetHeight; canvasRef.current.width = canvasSize.current.w * dpr; canvasRef.current.height = canvasSize.current.h * dpr; canvasRef.current.style.width = `${canvasSize.current.w}px`; canvasRef.current.style.height = `${canvasSize.current.h}px`; context.current.scale(dpr, dpr); } }; const circleParams = (): Circle => { const x = Math.floor(Math.random() * canvasSize.current.w); const y = Math.floor(Math.random() * canvasSize.current.h); const translateX = 0; const translateY = 0; const pSize = Math.floor(Math.random() * 2) + size; const alpha = 0; const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)); const dx = (Math.random() - 0.5) * 0.1; const dy = (Math.random() - 0.5) * 0.1; const magnetism = 0.1 + Math.random() * 4; return { x, y, translateX, translateY, size: pSize, alpha, targetAlpha, dx, dy, magnetism, }; }; const rgb = hexToRgb(color); const drawCircle = (circle: Circle, update = false) => { if (context.current) { const { x, y, translateX, translateY, size, alpha } = circle; context.current.translate(translateX, translateY); context.current.beginPath(); context.current.arc(x, y, size, 0, 2 * Math.PI); context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`; context.current.fill(); context.current.setTransform(dpr, 0, 0, dpr, 0, 0); if (!update) { circles.current.push(circle); } } }; const clearContext = () => { if (context.current) { context.current.clearRect( 0, 0, canvasSize.current.w, canvasSize.current.h, ); } }; const drawParticles = () => { clearContext(); const particleCount = quantity; for (let i = 0; i < particleCount; i++) { const circle = circleParams(); drawCircle(circle); } }; const remapValue = ( value: number, start1: number, end1: number, start2: number, end2: number, ): number => { const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2; return remapped > 0 ? remapped : 0; }; const animate = () => { clearContext(); circles.current.forEach((circle: Circle, i: number) => { // Handle the alpha value const edge = [ circle.x + circle.translateX - circle.size, // distance from left edge canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge circle.y + circle.translateY - circle.size, // distance from top edge canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge ]; const closestEdge = edge.reduce((a, b) => Math.min(a, b)); const remapClosestEdge = parseFloat( remapValue(closestEdge, 0, 20, 0, 1).toFixed(2), ); if (remapClosestEdge > 1) { circle.alpha += 0.02; if (circle.alpha > circle.targetAlpha) { circle.alpha = circle.targetAlpha; } } else { circle.alpha = circle.targetAlpha * remapClosestEdge; } circle.x += circle.dx + vx; circle.y += circle.dy + vy; circle.translateX += (mouse.current.x / (staticity / circle.magnetism) - circle.translateX) / ease; circle.translateY += (mouse.current.y / (staticity / circle.magnetism) - circle.translateY) / ease; drawCircle(circle, true); // circle gets out of the canvas if ( circle.x < -circle.size || circle.x > canvasSize.current.w + circle.size || circle.y < -circle.size || circle.y > canvasSize.current.h + circle.size ) { // remove the circle from the array circles.current.splice(i, 1); // create a new circle const newCircle = circleParams(); drawCircle(newCircle); // update the circle position } }); window.requestAnimationFrame(animate); }; return ( <div className={cn("pointer-events-none", className)} ref={canvasContainerRef} aria-hidden="true" > <canvas ref={canvasRef} className="size-full" /> </div> );};Particles.displayName = "Particles";
Installation
npx shadcn@latest add https://www.shadcn.io/registry/particles.json
npx shadcn@latest add https://www.shadcn.io/registry/particles.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/particles.json
bunx shadcn@latest add https://www.shadcn.io/registry/particles.json
Features
- Mouse Interaction: Particles respond to mouse movement with magnetic attraction
- Canvas-based Animation: Smooth 60fps animations using HTML5 Canvas
- Customizable Properties: Control quantity, size, color, speed, and behavior
- Performance Optimized: Efficient particle system with automatic cleanup
- Responsive Design: Adapts to container dimensions with automatic resize handling
- TypeScript Support: Fully typed with comprehensive interface definitions
Examples
Interactive Experience
Loading component...
import { Particles } from "@/components/ui/shadcn-io/particles";export default function ParticlesInteractive() { return ( <div className="relative flex h-[500px] w-full flex-col items-center justify-center overflow-hidden rounded-lg border bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 dark:from-slate-950 dark:via-purple-950 dark:to-slate-950 md:shadow-xl"> <div className="z-10 flex flex-col items-center gap-4"> <h1 className="text-3xl font-bold text-white"> Interactive Particles </h1> <p className="text-center text-sm text-white/80 max-w-md"> Move your mouse around to see the particles react to your cursor. The particles will follow your movement with a magnetic effect. </p> <button className="px-4 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white hover:bg-white/30 transition-colors"> Try It Out </button> </div> <Particles className="absolute inset-0" quantity={150} ease={60} staticity={30} color="#3b82f6" size={1.2} /> </div> );}
'use client';import React from "react";import { useEffect, useRef, useState } from "react";import { cn } from "@/lib/utils";interface MousePosition { x: number; y: number;}function useMousePosition(): MousePosition { const [mousePosition, setMousePosition] = useState<MousePosition>({ x: 0, y: 0, }); useEffect(() => { const handleMouseMove = (event: MouseEvent) => { setMousePosition({ x: event.clientX, y: event.clientY }); }; window.addEventListener("mousemove", handleMouseMove); return () => { window.removeEventListener("mousemove", handleMouseMove); }; }, []); return mousePosition;}export interface ParticlesProps { className?: string; quantity?: number; staticity?: number; ease?: number; size?: number; refresh?: boolean; color?: string; vx?: number; vy?: number;}function hexToRgb(hex: string): number[] { hex = hex.replace("#", ""); if (hex.length === 3) { hex = hex .split("") .map((char) => char + char) .join(""); } const hexInt = parseInt(hex, 16); const red = (hexInt >> 16) & 255; const green = (hexInt >> 8) & 255; const blue = hexInt & 255; return [red, green, blue];}export const Particles: React.FC<ParticlesProps> = ({ className = "", quantity = 100, staticity = 50, ease = 50, size = 0.4, refresh = false, color = "#ffffff", vx = 0, vy = 0,}) => { const canvasRef = useRef<HTMLCanvasElement>(null); const canvasContainerRef = useRef<HTMLDivElement>(null); const context = useRef<CanvasRenderingContext2D | null>(null); const circles = useRef<Circle[]>([]); const mousePosition = useMousePosition(); const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1; useEffect(() => { if (canvasRef.current) { context.current = canvasRef.current.getContext("2d"); } initCanvas(); animate(); window.addEventListener("resize", initCanvas); return () => { window.removeEventListener("resize", initCanvas); }; }, [color]); useEffect(() => { onMouseMove(); }, [mousePosition.x, mousePosition.y]); useEffect(() => { initCanvas(); }, [refresh]); const initCanvas = () => { resizeCanvas(); drawParticles(); }; const onMouseMove = () => { if (canvasRef.current) { const rect = canvasRef.current.getBoundingClientRect(); const { w, h } = canvasSize.current; const x = mousePosition.x - rect.left - w / 2; const y = mousePosition.y - rect.top - h / 2; const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2; if (inside) { mouse.current.x = x; mouse.current.y = y; } } }; type Circle = { x: number; y: number; translateX: number; translateY: number; size: number; alpha: number; targetAlpha: number; dx: number; dy: number; magnetism: number; }; const resizeCanvas = () => { if (canvasContainerRef.current && canvasRef.current && context.current) { circles.current.length = 0; canvasSize.current.w = canvasContainerRef.current.offsetWidth; canvasSize.current.h = canvasContainerRef.current.offsetHeight; canvasRef.current.width = canvasSize.current.w * dpr; canvasRef.current.height = canvasSize.current.h * dpr; canvasRef.current.style.width = `${canvasSize.current.w}px`; canvasRef.current.style.height = `${canvasSize.current.h}px`; context.current.scale(dpr, dpr); } }; const circleParams = (): Circle => { const x = Math.floor(Math.random() * canvasSize.current.w); const y = Math.floor(Math.random() * canvasSize.current.h); const translateX = 0; const translateY = 0; const pSize = Math.floor(Math.random() * 2) + size; const alpha = 0; const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)); const dx = (Math.random() - 0.5) * 0.1; const dy = (Math.random() - 0.5) * 0.1; const magnetism = 0.1 + Math.random() * 4; return { x, y, translateX, translateY, size: pSize, alpha, targetAlpha, dx, dy, magnetism, }; }; const rgb = hexToRgb(color); const drawCircle = (circle: Circle, update = false) => { if (context.current) { const { x, y, translateX, translateY, size, alpha } = circle; context.current.translate(translateX, translateY); context.current.beginPath(); context.current.arc(x, y, size, 0, 2 * Math.PI); context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`; context.current.fill(); context.current.setTransform(dpr, 0, 0, dpr, 0, 0); if (!update) { circles.current.push(circle); } } }; const clearContext = () => { if (context.current) { context.current.clearRect( 0, 0, canvasSize.current.w, canvasSize.current.h, ); } }; const drawParticles = () => { clearContext(); const particleCount = quantity; for (let i = 0; i < particleCount; i++) { const circle = circleParams(); drawCircle(circle); } }; const remapValue = ( value: number, start1: number, end1: number, start2: number, end2: number, ): number => { const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2; return remapped > 0 ? remapped : 0; }; const animate = () => { clearContext(); circles.current.forEach((circle: Circle, i: number) => { // Handle the alpha value const edge = [ circle.x + circle.translateX - circle.size, // distance from left edge canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge circle.y + circle.translateY - circle.size, // distance from top edge canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge ]; const closestEdge = edge.reduce((a, b) => Math.min(a, b)); const remapClosestEdge = parseFloat( remapValue(closestEdge, 0, 20, 0, 1).toFixed(2), ); if (remapClosestEdge > 1) { circle.alpha += 0.02; if (circle.alpha > circle.targetAlpha) { circle.alpha = circle.targetAlpha; } } else { circle.alpha = circle.targetAlpha * remapClosestEdge; } circle.x += circle.dx + vx; circle.y += circle.dy + vy; circle.translateX += (mouse.current.x / (staticity / circle.magnetism) - circle.translateX) / ease; circle.translateY += (mouse.current.y / (staticity / circle.magnetism) - circle.translateY) / ease; drawCircle(circle, true); // circle gets out of the canvas if ( circle.x < -circle.size || circle.x > canvasSize.current.w + circle.size || circle.y < -circle.size || circle.y > canvasSize.current.h + circle.size ) { // remove the circle from the array circles.current.splice(i, 1); // create a new circle const newCircle = circleParams(); drawCircle(newCircle); // update the circle position } }); window.requestAnimationFrame(animate); }; return ( <div className={cn("pointer-events-none", className)} ref={canvasContainerRef} aria-hidden="true" > <canvas ref={canvasRef} className="size-full" /> </div> );};Particles.displayName = "Particles";
API Reference
Particles
Prop | Type | Default | Description |
---|---|---|---|
className | string | - | Additional CSS classes to apply to the container |
quantity | number | 100 | Number of particles to render |
staticity | number | 50 | Controls how much particles are affected by mouse |
ease | number | 50 | Easing factor for particle movement |
size | number | 0.4 | Base size of particles |
refresh | boolean | false | Forces particles to regenerate when true |
color | string | "#ffffff" | Hex color code for particles |
vx | number | 0 | Horizontal velocity bias for particles |
vy | number | 0 | Vertical velocity bias for particles |
Usage Notes
- The component uses
absolute
positioning and should be placed within arelative
container - Content should have a higher
z-index
to appear above the particles - Particles automatically regenerate when they move outside the canvas bounds
- Mouse interaction is calculated relative to the canvas center
- The component is optimized for performance with requestAnimationFrame
- Edge detection ensures particles fade near container boundaries for smooth visual transitions
Customization
You can customize the particle behavior by adjusting the props:
- Magnetic Effect: Lower
staticity
values make particles more responsive to mouse movement - Smooth Movement: Higher
ease
values create smoother, slower particle transitions - Particle Density: Adjust
quantity
for more or fewer particles - Visual Style: Change
color
andsize
to match your design - Directional Flow: Use
vx
andvy
to create directional particle movement
Noise
Animated grain texture overlays with customizable patterns. Perfect for React applications requiring film-like visual effects with Next.js integration and TypeScript support.
Retro Grid
Nostalgic 80s-inspired grid animations that capture the essence of cyberpunk aesthetics. This React component brings sci-fi movie magic to modern Next.js applications with authentic retro appeal.