Particles
Interactive particle animation component for React and Next.js applications. Built with TypeScript support, HTML5 Canvas, and Tailwind CSS styling featuring mouse interaction, customizable behavior, and performance optimization.
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 - Magnetic particle attraction with real-time tracking using HTML5 Canvas for React apps
- 60fps animations - Smooth performance with requestAnimationFrame optimization using JavaScript
- Customizable behavior - Control quantity, size, color, speed, and movement patterns using TypeScript props
- Responsive canvas - Auto-resize handling with container dimension detection for Next.js applications
- Edge detection - Smooth particle fading at boundaries with automatic regeneration using Tailwind CSS
- Open source - Free particle system with comprehensive configuration options
Examples
Interactive Experience
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";
Use Cases
- Hero sections - Dynamic backgrounds with particle animation effects
- Interactive demos - Mouse-following effects for engagement and storytelling
- Portfolio sites - Creative backgrounds that respond to user interaction
- Gaming interfaces - Ambient particle effects for immersive experiences
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 |
Implementation
Uses HTML5 Canvas with absolute positioning. Place within relative container. Content needs higher z-index. Particles auto-regenerate at boundaries. Mouse interaction calculated from canvas center.
Interactive Grid Pattern
Interactive SVG grid background component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring hover effects, customizable dimensions, and responsive layouts.
Pin List
Interactive list component with pin/unpin functionality and smooth layout animations. Perfect for React applications requiring organized content management with Next.js integration and TypeScript support.