canvasparticles-js
Version:
In an HTML canvas, a bunch of interactive particles connected with lines when they approach each other.
959 lines (787 loc) • 34.7 kB
text/typescript
// Copyright (c) 2022–2026 Kyle Hoeckman, MIT License
// https://github.com/Khoeckman/canvasparticles-js/blob/main/LICENSE
import type { CanvasParticlesCanvas, Particle, GridPos, ContextColor, SpatialGrid } from './types'
import type { CanvasParticlesOptions, CanvasParticlesOptionsInput } from './types/options'
const TWO_PI = 2 * Math.PI
/** Extremely fast, simple 32‑bit PRNG */
function Mulberry32(seed: number) {
let state = seed >>> 0
return {
next() {
let t = (state + 0x6d2b79f5) | 0
state = t
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
},
}
}
// Mulberry32 is ±392% faster than Math.random()
// Benchmark: https://jsbm.dev/muLCWR9RJCbmy
// Spectral test: /demo/mulberry32.html
const prng = Mulberry32(Math.random() * 4294967296).next
// Injected by Rollup
declare const __VERSION__: string
export default class CanvasParticles {
/** Version of the library, injected via Rollup replace plugin. */
static readonly version = __VERSION__
private static readonly MAX_DT = 1000 / 30 // milliseconds between updates @ 30 FPS
private static readonly BASE_DT = 1000 / 60 // milliseconds between updates @ 60 FPS
/** Defines mouse interaction types with the particles */
static readonly interactionType = Object.freeze({
NONE: 0, // No mouse interaction
SHIFT: 1, // Visual displacement only
MOVE: 2, // Actual particle movement (default)
})
/** Defines how the particles are auto-generated */
static readonly generationType = Object.freeze({
OFF: 0, // Never auto-generate particles
NEW: 1, // Generate all particles from scratch
MATCH: 2, // Add or remove some particles to match the new count (default)
})
/** Observes canvas elements entering or leaving the viewport to start/stop animation */
static readonly canvasIntersectionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const canvas = entry.target as CanvasParticlesCanvas
const instance = canvas.instance // The CanvasParticles class instance bound to this canvas
if (!instance.options?.animation) return
if ((canvas.inViewbox = entry.isIntersecting))
instance.option.animation?.startOnEnter && instance.start({ auto: true })
else instance.option.animation?.stopOnLeave && instance.stop({ auto: true, clear: false })
}
},
{
rootMargin: '-1px',
}
)
static readonly canvasResizeObserver = new ResizeObserver((entries) => {
// Seperate for loops is very important to prevent huge forced reflow overhead
// First read all canvas rects at once
for (const entry of entries) {
const canvas = entry.target as CanvasParticlesCanvas
canvas.instance.updateCanvasRect()
}
// Cache to prevent fetching the dpr for every instance
const dpr = window.devicePixelRatio || 1
// Then resize all canvases at once
for (const entry of entries) {
const canvas = entry.target as CanvasParticlesCanvas
canvas.instance.#resizeCanvas(dpr)
}
})
/** Helper functions for options parsing */
private static defaultIfNaN(value: number, defaultValue: number): number {
return isNaN(+value) ? defaultValue : +value
}
private static parseNumericOption(
name: string,
value: number | undefined,
defaultValue: number,
clamp?: { min?: number; max?: number }
): number {
if (value == undefined) return defaultValue
const { min = -Infinity, max = Infinity } = clamp ?? {}
if (value < min) {
console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`))
} else if (value > max) {
console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`))
}
return CanvasParticles.defaultIfNaN(Math.min(Math.max(value ?? defaultValue, min), max), defaultValue)
}
canvas: CanvasParticlesCanvas
private ctx: CanvasRenderingContext2D
enableAnimating: boolean = false
isAnimating: boolean = false
private lastAnimationFrame: number = 0
particles: Particle[] = []
hasManualParticles = false // set to true once createParticle() is used
private clientX: number = Infinity
private clientY: number = Infinity
mouseX: number = Infinity
mouseY: number = Infinity
dpr: number = 1
width!: number
height!: number
private offX!: number
private offY!: number
option!: CanvasParticlesOptions
color!: ContextColor
/**
* Initialize a CanvasParticles instance
* @param selector - Canvas element or CSS selector
* @param options - Configuration object for particles (https://github.com/Khoeckman/canvasParticles?tab=readme-ov-file#options)
*/
constructor(selector: string | HTMLCanvasElement, options: CanvasParticlesOptionsInput = {}) {
let canvas
// Find the HTMLCanvasElement and assign it to `this.canvas`
if (selector instanceof HTMLCanvasElement) canvas = selector
else {
if (typeof selector !== 'string')
throw new TypeError('selector is not a string and neither a HTMLCanvasElement itself')
canvas = document.querySelector(selector)
if (!(canvas instanceof HTMLCanvasElement)) throw new Error('selector does not point to a canvas')
}
this.canvas = canvas as CanvasParticlesCanvas
this.canvas.instance = this // Circular assignment to find the instance bound to this canvas
this.canvas.inViewbox = true
// Get 2d drawing methods
const ctx = this.canvas.getContext('2d')
if (!ctx) throw new Error('failed to get 2D context from canvas')
this.ctx = ctx
this.options = options // Uses setter
CanvasParticles.canvasIntersectionObserver.observe(this.canvas)
CanvasParticles.canvasResizeObserver.observe(this.canvas)
// Setup event handlers
this.resizeCanvas = this.resizeCanvas.bind(this)
this.handleMouseMove = this.handleMouseMove.bind(this)
this.handleScroll = this.handleScroll.bind(this)
// this.resizeCanvas()
window.addEventListener('mousemove', this.handleMouseMove, { passive: true })
window.addEventListener('scroll', this.handleScroll, { passive: true })
}
updateCanvasRect() {
const { top, left, width, height } = this.canvas.getBoundingClientRect()
this.canvas.rect = { top, left, width, height }
}
handleMouseMove(event: MouseEvent) {
if (!this.enableAnimating) return
this.clientX = event.clientX
this.clientY = event.clientY
if (!this.isAnimating) return
this.updateMousePos()
}
handleScroll() {
if (!this.enableAnimating) return
this.updateCanvasRect()
if (!this.isAnimating) return
this.updateMousePos()
}
updateMousePos() {
const { top, left } = this.canvas.rect
this.mouseX = this.clientX - left
this.mouseY = this.clientY - top
}
/** Resize the canvas and update particles accordingly */
#resizeCanvas(dpr = window.devicePixelRatio || 1) {
const width = (this.canvas.width = this.canvas.rect.width * dpr)
const height = (this.canvas.height = this.canvas.rect.height * dpr)
// Must be set every time width or height changes because scale is removed
if (dpr !== 1) this.ctx.scale(dpr, dpr)
// Hide the mouse when resizing because it must be outside the viewport to do so
this.mouseX = Infinity
this.mouseY = Infinity
this.width = Math.max(width + this.option.particles.connectDist * 2, 1)
this.height = Math.max(height + this.option.particles.connectDist * 2, 1)
this.offX = (width - this.width) / 2
this.offY = (height - this.height) / 2
const generationType = this.option.particles.generationType
if (generationType !== CanvasParticles.generationType.OFF) {
if (generationType === CanvasParticles.generationType.NEW || this.particles.length === 0) this.newParticles()
else if (generationType === CanvasParticles.generationType.MATCH) this.matchParticleCount({ updateBounds: true })
}
if (this.isAnimating) this.#render()
}
/** Update the canvas bounding rectangle (optional), resize the canvas and update particles accordingly */
resizeCanvas(updateRect = true) {
if (updateRect) this.updateCanvasRect()
this.#resizeCanvas()
}
/** Update the target number of particles based on the current canvas size and `option.particles.ppm`, capped at `option.particles.max`. */
#targetParticleCount(): number {
// Amount of particles to be created
let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000)
particleCount = Math.min(this.option.particles.max, particleCount)
if (!isFinite(particleCount)) throw new RangeError('particleCount must be finite')
return particleCount | 0
}
/** Remove existing particles and generate new ones */
newParticles({ keepAuto = false, keepManual = true } = {}) {
const particleCount = this.#targetParticleCount()
if (this.hasManualParticles && (keepAuto || keepManual)) {
this.particles = this.particles.filter(
(particle) => (keepAuto && !particle.isManual) || (keepManual && particle.isManual)
)
this.hasManualParticles = this.particles.length > 0
} else {
this.particles = []
}
if (!keepAuto) {
for (let i = 0; i < particleCount; i++) this.#createParticle()
}
}
/** Adjust particle array length to match `option.particles.ppm` */
matchParticleCount({ updateBounds = false }: { updateBounds?: boolean } = {}) {
const particleCount = this.#targetParticleCount()
if (this.hasManualParticles) {
const pruned: Particle[] = []
let autoCount = 0
// Keep manual particles while pruning automatic particles that exceed `particleCount`
// Only count automatic particles towards `particledCount`
for (const particle of this.particles) {
if (particle.isManual) {
pruned.push(particle)
continue
}
if (autoCount >= particleCount) continue
pruned.push(particle)
autoCount++
}
this.particles = pruned
} else {
this.particles = this.particles.slice(0, particleCount)
}
// Only necessary after resize
if (updateBounds) {
for (const particle of this.particles) {
this.#updateParticleBounds(particle)
}
}
for (let i = this.particles.length; i < particleCount; i++) this.#createParticle()
}
/** Create a random new particle */
#createParticle() {
const posX = prng() * this.width
const posY = prng() * this.height
this.createParticle(
posX,
posY,
prng() * TWO_PI,
(0.5 + prng() * 0.5) * this.option.particles.relSpeed,
(0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize,
false
)
}
/** Create a new particle with optional parameters */
createParticle(posX: number, posY: number, dir: number, speed: number, size: number, isManual = true) {
const particle: Omit<Particle, 'bounds'> = {
posX, // Logical position in pixels
posY, // Logical position in pixels
x: posX, // Visual position in pixels
y: posY, // Visual position in pixels
velX: 0, // Horizonal speed in pixels per update
velY: 0, // Vertical speed in pixels per update
offX: 0, // Horizontal distance from drawn to logical position in pixels
offY: 0, // Vertical distance from drawn to logical position in pixels
dir: dir, // Direction in radians
speed: speed, // Velocity in pixels per update
size: size, // Ray in pixels of the particle
gridPos: { x: 1, y: 1 },
isVisible: false,
isManual,
}
this.#updateParticleBounds(particle)
this.particles.push(particle)
this.hasManualParticles = true
}
/** Update the visible bounds of a particle */
#updateParticleBounds(
particle: Omit<Particle, 'bounds'> & Partial<Pick<Particle, 'bounds'>> // Make bounds optional on particle
): asserts particle is Particle {
// The particle is considered visible within these bounds
particle.bounds = {
top: -particle.size,
right: this.canvas.width + particle.size,
bottom: this.canvas.height + particle.size,
left: -particle.size,
}
}
/* Randomize speed and size of all particles based on current options */
updateParticles() {
const relSpeed = this.option.particles.relSpeed
const relSize = this.option.particles.relSize
for (const particle of this.particles) {
particle.speed = (0.5 + prng() * 0.5) * relSpeed
particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize
this.#updateParticleBounds(particle) // because size changed
}
}
/** Apply gravity forces between particles */
#updateGravity(step: number) {
const isRepulsiveEnabled = this.option.gravity.repulsive > 0
const isPullingEnabled = this.option.gravity.pulling > 0
if (!isRepulsiveEnabled && !isPullingEnabled) return
const particles = this.particles
const len = particles.length
const connectDist = this.option.particles.connectDist
const gravRepulsiveMult = connectDist * this.option.gravity.repulsive * step
const gravPullingMult = connectDist * this.option.gravity.pulling * step
const maxRepulsiveDist = connectDist / 2
const maxRepulsiveDistSq = maxRepulsiveDist ** 2
const epsilon = connectDist ** 2 / 256
for (let a = 0; a < len; a++) {
const pa = particles[a]
for (let b = a + 1; b < len; b++) {
// Code in this scope runs O(n^2) times per frame!
const pb = particles[b]
const distX = pa.posX - pb.posX
const distY = pa.posY - pb.posY
const distSq = distX * distX + distY * distY
if (distSq >= maxRepulsiveDistSq && !isPullingEnabled) continue
const invSqrt = 1 / Math.sqrt(distSq + epsilon)
const invDist = invSqrt * invSqrt * invSqrt
if (distSq < maxRepulsiveDistSq) {
const grav = invDist * gravRepulsiveMult
const gravX = -distX * grav
const gravY = -distY * grav
pa.velX -= gravX
pa.velY -= gravY
pb.velX += gravX
pb.velY += gravY
}
if (!isPullingEnabled) continue
const grav = invDist * gravPullingMult
const gravX = -distX * grav
const gravY = -distY * grav
pa.velX += gravX
pa.velY += gravY
pb.velX -= gravX
pb.velY -= gravY
}
}
}
/** Update positions, directions, and visibility of all particles */
#updateParticles(step: number) {
const width = this.width
const height = this.height
const offX = this.offX
const offY = this.offY
const mouseX = this.mouseX
const mouseY = this.mouseY
const isMouseInteractionTypeNone = this.option.mouse.interactionType === CanvasParticles.interactionType.NONE
const isMouseInteractionTypeMove = this.option.mouse.interactionType === CanvasParticles.interactionType.MOVE
const mouseConnectDist = this.option.mouse.connectDist
const mouseDistRatio = this.option.mouse.distRatio
const rotationSpeed = this.option.particles.rotationSpeed * step
const friction = this.option.gravity.friction
const maxVel = this.option.gravity.maxVelocity
const easing = 1 - Math.pow(3 / 4, step)
for (const p of this.particles) {
p.dir += 2 * (Math.random() - 0.5) * rotationSpeed * step
p.dir %= TWO_PI
// Constant velocity
const movX = Math.sin(p.dir) * p.speed
const movY = Math.cos(p.dir) * p.speed
// Maximum velocity
if (maxVel > 0) {
if (p.velX > maxVel) p.velX = maxVel
if (p.velX < -maxVel) p.velX = -maxVel
if (p.velY > maxVel) p.velY = maxVel
if (p.velY < -maxVel) p.velY = -maxVel
}
// Apply velocities
p.posX += (movX + p.velX) * step
p.posY += (movY + p.velY) * step
// Wrap particles around the canvas
p.posX %= width
if (p.posX < 0) p.posX += width
p.posY %= height
if (p.posY < 0) p.posY += height
// Slightly decrease dynamic velocity
p.velX *= Math.pow(friction, step)
p.velY *= Math.pow(friction, step)
// Distance from mouse
const distX = p.posX + offX - mouseX
const distY = p.posY + offY - mouseY
// Mouse interaction
if (!isMouseInteractionTypeNone) {
const distRatio = mouseConnectDist / Math.hypot(distX, distY)
if (mouseDistRatio < distRatio) {
p.offX += (distRatio * distX - distX - p.offX) * easing
p.offY += (distRatio * distY - distY - p.offY) * easing
} else {
p.offX -= p.offX * easing
p.offY -= p.offY * easing
}
}
// Visually displace the particles
p.x = p.posX + p.offX
p.y = p.posY + p.offY
// Move the particles
if (isMouseInteractionTypeMove) {
p.posX = p.x
p.posY = p.y
}
p.x += offX
p.y += offY
/**
* Determine a particle's location in a 3x3 canvas grid to assess visibility.
*
* This helps identify whether two particles, even if off-canvas, might have a visible connection.
*
* Grid regions:
* - { x: 0, y: 0 } = top-left
* - { x: 1, y: 0 } = top
* - { x: 2, y: 0 } = top-right
* - { x: 0, y: 1 } = left
* - { x: 1, y: 1 } = center (visible part of the canvas)
* - { x: 2, y: 1 } = right
* - { x: 0, y: 2 } = bottom-left
* - { x: 1, y: 2 } = bottom
* - { x: 2, y: 2 } = bottom-right
*/
p.gridPos.x = (+(p.x >= p.bounds.left) + +(p.x > p.bounds.right)) as GridPos
p.gridPos.y = (+(p.y >= p.bounds.top) + +(p.y > p.bounds.bottom)) as GridPos
p.isVisible = p.gridPos.x === 1 && p.gridPos.y === 1
}
}
/** Draw the particles on the canvas */
#renderParticles() {
const ctx = this.ctx
for (const p of this.particles) {
if (!p.isVisible) continue
// Draw particles smaller than 1px as a square instead of a circle for performance
if (p.size > 1) {
// Draw circle
ctx.beginPath()
ctx.arc(p.x, p.y, p.size, 0, TWO_PI)
ctx.fill()
ctx.closePath()
} else {
// Draw square (±183% faster)
ctx.fillRect(p.x - p.size, p.y - p.size, p.size * 2, p.size * 2)
}
}
}
/** @private */
#buildSpatialGrid(stride: number, invCellSize: number): SpatialGrid {
const particles = this.particles
const len = particles.length
const grid: SpatialGrid = new Map()
for (let i = 0; i < len; i++) {
const p = particles[i]
const key = ((p.x * invCellSize) | 0) + Math.imul(p.y * invCellSize, stride)
const cell = grid.get(key)
if (cell) cell.push(i)
else grid.set(key, [i])
}
return grid
}
/** Determines whether a line between 2 particles crosses through the visible center of the canvas */
static #isLineVisible(particleA: Particle, particleB: Particle) {
// Visible if either particle is in the center
if (particleA.isVisible || particleB.isVisible) return true
// Not visible if both particles are in the same vertical or horizontal line that does not cross the center
return !(
(particleA.gridPos.x === particleB.gridPos.x && particleA.gridPos.x !== 1) ||
(particleA.gridPos.y === particleB.gridPos.y && particleA.gridPos.y !== 1)
)
}
/** Draw lines between particles if they are close enough */
#renderConnections() {
const particles = this.particles
const len = particles.length
const ctx = this.ctx
const maxDist = this.option.particles.connectDist
const maxDistSq = maxDist ** 2
const halfMaxDistSq = (maxDist / 2) ** 2
const invCellSize = 1 / maxDist
const stride = Math.ceil(this.width * invCellSize)
const drawAll = maxDist >= Math.min(this.canvas.width, this.canvas.height)
const maxWorkPerParticle = maxDistSq * this.option.particles.maxWork
const alpha = this.color.alpha
const alphaFactor = this.color.alpha * maxDist
const bucket: number[] = [] // Batch line segments of max alpha (2D -> 1D; stride = 4)
const grid = this.#buildSpatialGrid(stride, invCellSize) // O(n^2) -> O(n)
let particleWork = 0
let allowWork = true
function renderConnection(ax: number, ay: number, bx: number, by: number) {
const distX = ax - bx
const distY = ay - by
const distSq = distX * distX + distY * distY
// Don't draw the line if the particles are too far away
if (distSq > maxDistSq) return
if (distSq > halfMaxDistSq) {
ctx.globalAlpha = alphaFactor / Math.sqrt(distSq) - alpha
ctx.beginPath()
ctx.moveTo(ax, ay)
ctx.lineTo(bx, by)
ctx.stroke()
} else {
// Cache lines with max alpha to later be drawn in one batch
bucket.push(ax, ay, bx, by)
}
particleWork += distSq
allowWork = particleWork < maxWorkPerParticle
}
function renderConnectionsToOwnCell(cell: number[], a: number, pa: Particle) {
// Loops though indexes of particles in `this.particles`
for (const b of cell) {
if (a >= b) continue // Skip self and particles that already drew a line in the opposite direction
const pb = particles[b]
// Don't draw the line if it wouldn't be visible
if (!drawAll && !CanvasParticles.#isLineVisible(pa, pb)) continue
renderConnection(pa.x, pa.y, pb.x, pb.y)
// Stop drawing lines from this particle if it has exceeded what's allowed by configuration
if (!allowWork) break
}
}
function renderConnectionsToCell(cell: number[], pa: Particle) {
// Loops though indexes of particles in `this.particles`
for (const b of cell) {
const pb = particles[b]
// Don't draw the line if it wouldn't be visible
if (!drawAll && !CanvasParticles.#isLineVisible(pa, pb)) continue
renderConnection(pa.x, pa.y, pb.x, pb.y)
// Stop drawing lines from this particle if it has exceeded what's allowed by configuration
if (!allowWork) break
}
}
for (let a = 0; a < len; a++) {
particleWork = 0
allowWork = true
/**
* 3x3 Grid Hop
* Fastest approach: https://jsbm.dev/XIRm7thFFw82v (Unrolled: Positive Only)
*
* Cells with negative dx and dy can be skipped since they will at one point be the
* selected cell and do their own grid hop which will include the current cell
*/
let pa = particles[a]
let cellX = (pa.x * invCellSize) | 0
let cellY = (pa.y * invCellSize) | 0
let key = cellX + Math.imul(cellY, stride)
let cell
if ((cell = grid.get(key + 1))) renderConnectionsToCell(cell, pa) // (+1, 0)
if (!allowWork) continue
if ((cell = grid.get(key + stride))) renderConnectionsToCell(cell, pa) // (0, +1)
if (!allowWork) continue
if ((cell = grid.get(key + stride + 1))) renderConnectionsToCell(cell, pa) // (+1, +1)
if (!allowWork) continue
if ((cell = grid.get(key + stride - 1))) renderConnectionsToCell(cell, pa) // (-1, +1)
if (!allowWork) continue
if (cellX >= 0 && cellY >= 0 && cellX < stride - 2 && (cell = grid.get(key)))
renderConnectionsToOwnCell(cell || [], a, pa)
// Next iteration
if (++a >= len) break
// Same code inline but the order of grid.get() is different to remove maxWork artifacts
particleWork = 0
allowWork = true
pa = particles[a]
cellX = (pa.x * invCellSize) | 0
cellY = (pa.y * invCellSize) | 0
key = cellX + Math.imul(cellY, stride)
if ((cell = grid.get(key + stride + 1))) renderConnectionsToCell(cell, pa) // (+1, +1)
if (!allowWork) continue
if ((cell = grid.get(key + stride - 1))) renderConnectionsToCell(cell, pa) // (-1, +1)
if (!allowWork) continue
if ((cell = grid.get(key + 1))) renderConnectionsToCell(cell, pa) // (+1, 0)
if (!allowWork) continue
if ((cell = grid.get(key + stride))) renderConnectionsToCell(cell, pa) // (0, +1)
if (!allowWork) continue
if (cellX >= 0 && cellY >= 0 && cellX < stride - 2 && (cell = grid.get(key)))
renderConnectionsToOwnCell(cell || [], a, pa)
// Next iteration
if (++a >= len) break
// Same code inline but the order of grid.get() is different to remove maxWork artifacts
particleWork = 0
allowWork = true
pa = particles[a]
cellX = (pa.x * invCellSize) | 0
cellY = (pa.y * invCellSize) | 0
key = cellX + Math.imul(cellY, stride)
if ((cell = grid.get(key + stride))) renderConnectionsToCell(cell, pa) // (0, +1)
if (!allowWork) continue
if ((cell = grid.get(key + 1))) renderConnectionsToCell(cell, pa) // (+1, 0)
if (!allowWork) continue
if (cellX >= 0 && cellY >= 0 && cellX < stride - 2 && (cell = grid.get(key)))
renderConnectionsToOwnCell(cell || [], a, pa)
if (!allowWork) continue
if ((cell = grid.get(key + stride - 1))) renderConnectionsToCell(cell, pa) // (-1, +1)
if (!allowWork) continue
if ((cell = grid.get(key + stride + 1))) renderConnectionsToCell(cell, pa) // (+1, +1)
}
if (!bucket.length) return
// Render all bucketed lines at once
ctx.globalAlpha = alpha
ctx.beginPath()
for (let line = 0; line < bucket.length; line += 4) {
ctx.moveTo(bucket[line], bucket[line + 1])
ctx.lineTo(bucket[line + 2], bucket[line + 3])
}
ctx.stroke()
}
#renderGrid(cellSize: number) {
const ctx = this.ctx
const { width, height } = this.canvas
ctx.save()
ctx.globalAlpha = 0.5
ctx.beginPath()
for (let x = 0.5; x <= width; x += cellSize) {
ctx.moveTo(x, 0)
ctx.lineTo(x, height)
}
for (let y = 0.5; y <= height; y += cellSize) {
ctx.moveTo(0, y)
ctx.lineTo(width, y)
}
ctx.stroke()
ctx.restore()
}
#renderParticleIndexes() {
const ctx = this.ctx
const particles = this.particles
const len = particles.length
ctx.save()
ctx.globalAlpha = 1
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
for (let i = 0; i < len; i++) {
const p = particles[i]
ctx.fillText(String(i), p.x, p.y)
}
ctx.restore()
}
/* Move all particles by one step based on how much time passed */
#update() {
const now = performance.now()
// Elapsed time since last frame, clamped to avoid large simulation jumps
const dt = Math.min(now - this.lastAnimationFrame, CanvasParticles.MAX_DT)
// Normalized simulation step:
// - step = 1 → exactly one baseline update (dt === BASE_DT)
// - step > 1 → more time passed (lower FPS), advance further
// - step < 1 → less time passed (higher FPS), advance less
const step = dt / CanvasParticles.BASE_DT
this.#updateGravity(step)
this.#updateParticles(step)
this.lastAnimationFrame = now
}
/** Clear the canvas and render the particles and their connections onto the canvas */
#render() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.globalAlpha = this.color.alpha
this.ctx.fillStyle = this.color.hex
this.ctx.strokeStyle = this.color.hex
this.ctx.lineWidth = 1
this.#renderParticles()
if (this.option.particles.drawLines) this.#renderConnections()
if (this.option.debug.drawGrid) this.#renderGrid(this.option.particles.connectDist)
if (this.option.debug.drawIndexes) this.#renderParticleIndexes()
}
/** Main animation loop that updates and renders the particles */
#animation() {
if (!this.isAnimating) return
requestAnimationFrame(() => this.#animation())
this.#update()
this.#render()
}
/** Start the particle animation if it was not running before */
start({ auto = false }: { auto?: boolean } = {}): CanvasParticles {
if (!this.isAnimating && (!auto || this.enableAnimating)) {
this.enableAnimating = true
this.isAnimating = true
this.updateCanvasRect()
requestAnimationFrame(() => this.#animation())
}
// Stop animating because it will start automatically once the canvas enters the viewbox
if (!this.canvas.inViewbox && this.option.animation.startOnEnter) this.isAnimating = false
return this
}
/** Stops the particle animation and optionally clears the canvas */
stop({ auto = false, clear = true }: { auto?: boolean; clear?: boolean } = {}): boolean {
if (!auto) this.enableAnimating = false
this.isAnimating = false
if (clear !== false) this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
return true
}
/** Gracefully destroy the instance and remove the canvas element */
destroy() {
this.stop()
CanvasParticles.canvasIntersectionObserver.unobserve(this.canvas)
CanvasParticles.canvasResizeObserver.unobserve(this.canvas)
window.removeEventListener('mousemove', this.handleMouseMove)
window.removeEventListener('scroll', this.handleScroll)
this.canvas?.remove()
Object.keys(this).forEach((key) => delete (this as any)[key]) // Remove references to help GC
}
/** Set and validate options (https://github.com/Khoeckman/canvasParticles?tab=readme-ov-file#options) */
set options(options: CanvasParticlesOptionsInput) {
const pno = CanvasParticles.parseNumericOption
// Format and parse all options
this.option = {
background: options.background ?? false,
animation: {
startOnEnter: !!(options.animation?.startOnEnter ?? true),
stopOnLeave: !!(options.animation?.stopOnLeave ?? true),
},
mouse: {
interactionType: ~~pno(
'mouse.interactionType',
options.mouse?.interactionType,
CanvasParticles.interactionType.MOVE,
{ min: 0, max: 2 }
) as 0 | 1 | 2,
connectDistMult: pno('mouse.connectDistMult', options.mouse?.connectDistMult, 2 / 3, { min: 0 }),
connectDist: 1 /* post processed */,
distRatio: pno('mouse.distRatio', options.mouse?.distRatio, 2 / 3, { min: 0 }),
},
particles: {
generationType: ~~pno(
'particles.generationType',
options.particles?.generationType,
CanvasParticles.generationType.MATCH,
{ min: 0, max: 2 }
) as 0 | 1 | 2,
drawLines: !!(options.particles?.drawLines ?? true),
color: options.particles?.color ?? 'black',
ppm: ~~pno('particles.ppm', options.particles?.ppm, 100),
max: Math.round(pno('particles.max', options.particles?.max, Infinity, { min: 0 })),
maxWork: Math.round(pno('particles.maxWork', options.particles?.maxWork, Infinity, { min: 0 })),
connectDist: ~~pno('particles.connectDistance', options.particles?.connectDistance, 150, { min: 1 }),
relSpeed: pno('particles.relSpeed', options.particles?.relSpeed, 1, { min: 0 }),
relSize: pno('particles.relSize', options.particles?.relSize, 1, { min: 0 }),
rotationSpeed: pno('particles.rotationSpeed', options.particles?.rotationSpeed, 2, { min: 0 }) / 100,
},
gravity: {
repulsive: pno('gravity.repulsive', options.gravity?.repulsive, 0, { min: 0 }),
pulling: pno('gravity.pulling', options.gravity?.pulling, 0, { min: 0 }),
friction: pno('gravity.friction', options.gravity?.friction, 0.8, { min: 0, max: 1 }),
maxVelocity: pno('gravity.maxVelocity', options.gravity?.maxVelocity, Infinity, { min: 0 }),
},
debug: {
drawGrid: !!options.debug?.drawGrid,
drawIndexes: !!options.debug?.drawIndexes,
},
}
this.setBackground(this.option.background)
this.setMouseConnectDistMult(this.option.mouse.connectDistMult)
this.setParticleColor(this.option.particles.color)
}
get options(): CanvasParticlesOptions {
return this.option
}
/** Sets the canvas background */
setBackground(background: CanvasParticlesOptionsInput['background']) {
if (!background) return
if (typeof background !== 'string') throw new TypeError('background is not a string')
this.canvas.style.background = this.option.background = background
}
/** Transform the distance multiplier (float) to absolute distance (px) */
setMouseConnectDistMult(connectDistMult: number) {
const mult = CanvasParticles.parseNumericOption('mouse.connectDistMult', connectDistMult, 2 / 3, { min: 0 })
this.option.mouse.connectDist = this.option.particles.connectDist * mult
}
/** Format particle color and opacity */
setParticleColor(color: string | CanvasGradient | CanvasPattern) {
this.ctx.fillStyle = color
// Check if `ctx.fillStyle` is in hex format ("#RRGGBB")
if (String(this.ctx.fillStyle)[0] === '#') {
this.color = {
hex: String(this.ctx.fillStyle),
alpha: 1.0,
}
} else {
// JavaScript's `ctx.fillStyle` causes the color to otherwise end up in in rgba format ("rgba(136, 244, 255, 0.25)")
// Extract the alpha value from the rgba string
let alpha = String(this.ctx.fillStyle).split(',').at(-1) // ' 0.25)'
alpha = alpha?.slice(1, -1) ?? '1' // '0.25'
// Extracts e.g. 136, 244 and 255 from rgba(136, 244, 255, 0.25) and converts it to '#rrggbb'
this.ctx.fillStyle = String(this.ctx.fillStyle).split(',').slice(0, -1).join(',') + ', 1)'
this.color = {
hex: String(this.ctx.fillStyle),
alpha: isNaN(+alpha) ? 1 : +alpha,
} // 0.25 or 1
}
}
}