hover-effects-ts
Version:
A collection of beautiful hover effects for images using canvas
596 lines (489 loc) • 19.5 kB
text/typescript
import { HoverEffect } from '../types';
export default class DotMatrixHover implements HoverEffect {
private readonly id: string;
private element: HTMLImageElement | null = null;
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private tempCanvas: HTMLCanvasElement | null = null;
private tempCtx: CanvasRenderingContext2D | null = null;
private isHovering = false;
private mousePos = { x: 0, y: 0 };
private animationFrame: number | null = null;
private imageData: ImageData | null = null;
private lastRenderTime = 0;
private readonly FPS = 30;
private readonly frameInterval = 1000 / 30; // 30 FPS
private frameCount = 0; // Add frame counter for animations
// Pre-built circle path for performance
private ledPath: Path2D | null = null;
// Configuration options
private radius: number;
private softEdge: number;
private ledSize: number;
private ledSpacing: number;
private scale: number;
private glow: boolean;
private fadeExp: number;
private colorMode: 'mono' | 'rgb';
// Animation properties
private animationType: 'none' | 'wave' | 'pulse' | 'drift' | 'ripple' | 'rotation';
private animationSpeed: number;
private animationIntensity: number;
private driftOffsets: Array<{x: number, y: number, vx: number, vy: number}> = [];
constructor(options: {
radius?: number;
softEdge?: number;
ledSize?: number;
ledSpacing?: number;
scale?: number;
glow?: boolean;
fadeExp?: number;
colorMode?: 'mono' | 'rgb';
animationType?: 'none' | 'wave' | 'pulse' | 'drift' | 'ripple' | 'rotation';
animationSpeed?: number;
animationIntensity?: number;
} = {}) {
this.id = Math.random().toString(36).substring(2, 9);
this.radius = options.radius ?? 120;
this.softEdge = options.softEdge ?? 20;
this.ledSize = options.ledSize ?? 4;
this.ledSpacing = options.ledSpacing ?? 5;
this.scale = options.scale ?? 0.12;
this.glow = options.glow ?? true;
this.fadeExp = options.fadeExp ?? 2;
this.colorMode = options.colorMode ?? 'mono';
this.animationType = options.animationType ?? 'wave';
this.animationSpeed = options.animationSpeed ?? 1.0;
this.animationIntensity = options.animationIntensity ?? 3.0;
this.buildLedPath();
}
private buildLedPath(): void {
this.ledPath = new Path2D();
this.ledPath.arc(0, 0, this.ledSize / 2, 0, Math.PI * 2);
}
private initDriftOffsets(gridWidth: number, gridHeight: number): void {
this.driftOffsets = [];
for (let gridY = 0; gridY < gridHeight; gridY++) {
for (let gridX = 0; gridX < gridWidth; gridX++) {
this.driftOffsets.push({
x: 0,
y: 0,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5
});
}
}
}
private updateDriftOffsets(): void {
this.driftOffsets.forEach(offset => {
// Update position
offset.x += offset.vx * this.animationSpeed;
offset.y += offset.vy * this.animationSpeed;
// Add some randomness to velocity
if (Math.random() < 0.02) {
offset.vx += (Math.random() - 0.5) * 0.1;
offset.vy += (Math.random() - 0.5) * 0.1;
}
// Limit velocity
const maxVel = 0.8;
offset.vx = Math.max(-maxVel, Math.min(maxVel, offset.vx));
offset.vy = Math.max(-maxVel, Math.min(maxVel, offset.vy));
// Limit position offset
const maxOffset = this.animationIntensity;
offset.x = Math.max(-maxOffset, Math.min(maxOffset, offset.x));
offset.y = Math.max(-maxOffset, Math.min(maxOffset, offset.y));
});
}
private getAnimationOffset(gridX: number, gridY: number, gridWidth: number, gridHeight: number): {x: number, y: number, scale: number, rotation: number} {
const time = this.frameCount * 0.1 * this.animationSpeed;
const intensity = this.animationIntensity;
switch (this.animationType) {
case 'wave':
return {
x: Math.sin(time + gridX * 0.3) * intensity,
y: Math.cos(time + gridY * 0.3) * intensity,
scale: 1 + Math.sin(time + (gridX + gridY) * 0.2) * 0.3,
rotation: 0
};
case 'pulse':
const pulsePhase = time + (gridX + gridY) * 0.1;
const pulse = Math.sin(pulsePhase) * 0.5 + 0.5;
return {
x: 0,
y: 0,
scale: 1 + pulse * 0.8,
rotation: 0
};
case 'drift':
const index = gridY * gridWidth + gridX;
const drift = this.driftOffsets[index] || { x: 0, y: 0, vx: 0, vy: 0 };
return {
x: drift.x,
y: drift.y,
scale: 1,
rotation: 0
};
case 'ripple':
const centerX = this.mousePos.x / this.ledSpacing;
const centerY = this.mousePos.y / this.ledSpacing;
const dist = Math.hypot(gridX - centerX, gridY - centerY);
const ripple = Math.sin(time * 2 - dist * 0.5) * intensity;
return {
x: 0,
y: 0,
scale: 1 + ripple * 0.3,
rotation: 0
};
case 'rotation':
const rotationSpeed = time * 0.5;
const rotationRadius = intensity * 0.5;
return {
x: Math.cos(rotationSpeed + gridX * 0.1) * rotationRadius,
y: Math.sin(rotationSpeed + gridY * 0.1) * rotationRadius,
scale: 1,
rotation: rotationSpeed + (gridX + gridY) * 0.1
};
default:
return { x: 0, y: 0, scale: 1, rotation: 0 };
}
}
private onMouseEnter = (e: MouseEvent): void => {
if (!this.element || !this.canvas) return;
this.isHovering = true;
// Get accurate cursor position relative to the image
const rect = this.element.getBoundingClientRect();
this.mousePos = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
this.canvas.style.opacity = '1';
this.lastRenderTime = 0; // Reset last render time to force immediate render
this.render();
};
private onMouseLeave = (): void => {
if (!this.canvas || !this.ctx) return;
this.isHovering = false;
this.canvas.style.opacity = '0';
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
setTimeout(() => {
if (!this.isHovering && this.ctx && this.canvas) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}, 300);
};
private onMouseMove = (e: MouseEvent): void => {
if (!this.element || !this.canvas) return;
// Get accurate cursor position relative to the canvas
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
this.mousePos = {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY
};
};
private updateImageData(): void {
if (!this.element || !this.tempCanvas || !this.tempCtx || !this.canvas) return;
const width = this.canvas.width;
const height = this.canvas.height;
// Validate canvas dimensions
if (width <= 0 || height <= 0) {
console.warn('Canvas dimensions are invalid, skipping image data update');
return;
}
// Calculate grid dimensions based on LED spacing
const gridWidth = Math.floor(width / this.ledSpacing);
const gridHeight = Math.floor(height / this.ledSpacing);
// Ensure grid dimensions are valid
if (gridWidth <= 0 || gridHeight <= 0) {
console.warn('Grid dimensions are invalid, skipping image data update');
return;
}
// Set temp canvas size for sampling
this.tempCanvas.width = gridWidth;
this.tempCanvas.height = gridHeight;
// Draw the image to the temporary canvas at grid resolution
this.tempCtx.drawImage(this.element, 0, 0, gridWidth, gridHeight);
// Get image data for processing
this.imageData = this.tempCtx.getImageData(0, 0, gridWidth, gridHeight);
}
private render = (timestamp = 0): void => {
if (!this.element || !this.canvas || !this.ctx || !this.imageData || !this.isHovering || !this.ledPath) return;
// Additional safety check for imageData
if (!this.imageData.data || this.imageData.width <= 0 || this.imageData.height <= 0) {
console.warn('Invalid image data, skipping render');
return;
}
// Check if enough time has passed since last render
const elapsed = timestamp - this.lastRenderTime;
if (elapsed < this.frameInterval) {
this.animationFrame = requestAnimationFrame(this.render);
return;
}
this.lastRenderTime = timestamp;
this.frameCount++; // Increment frame counter for animations
// Use displayed dimensions
const width = this.canvas.width;
const height = this.canvas.height;
// Calculate grid dimensions
const gridWidth = Math.floor(width / this.ledSpacing);
const gridHeight = Math.floor(height / this.ledSpacing);
// Ensure grid dimensions match imageData
if (gridWidth !== this.imageData.width || gridHeight !== this.imageData.height) {
console.warn('Grid dimensions mismatch with imageData, updating...');
this.updateImageData();
return;
}
// Initialize drift offsets if needed
if (this.animationType === 'drift' && this.driftOffsets.length !== gridWidth * gridHeight) {
this.initDriftOffsets(gridWidth, gridHeight);
}
// Update drift offsets if using drift animation
if (this.animationType === 'drift') {
this.updateDriftOffsets();
}
// Clear the canvas with black background (LED matrix background)
this.ctx.fillStyle = '#000000';
this.ctx.fillRect(0, 0, width, height);
// Set composite operation
this.ctx.globalCompositeOperation = 'source-over';
const data = this.imageData.data;
const effectiveRadius = this.radius;
// Iterate through the LED grid
for (let gridY = 0; gridY < gridHeight; gridY++) {
for (let gridX = 0; gridX < gridWidth; gridX++) {
// Calculate base LED position on canvas
const baseLedX = (gridX + 0.5) * this.ledSpacing;
const baseLedY = (gridY + 0.5) * this.ledSpacing;
// Get animation offset
const animOffset = this.getAnimationOffset(gridX, gridY, gridWidth, gridHeight);
// Apply animation offset to LED position
const ledX = baseLedX + animOffset.x;
const ledY = baseLedY + animOffset.y;
// Calculate distance from mouse (using base position for radius calculation)
const dist = Math.hypot(baseLedX - this.mousePos.x, baseLedY - this.mousePos.y);
// Only render LEDs within the hover radius
if (dist < effectiveRadius) {
const index = (gridY * gridWidth + gridX) * 4;
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
// Calculate brightness (0-1)
const brightness = (r + g + b) / (3 * 255);
// Calculate radial falloff with soft edge
let radialFalloff = 1;
if (dist > effectiveRadius - this.softEdge) {
const edgeDistance = dist - (effectiveRadius - this.softEdge);
radialFalloff = 1 - Math.pow(edgeDistance / this.softEdge, this.fadeExp);
radialFalloff = Math.max(0, radialFalloff);
}
// Calculate final alpha
const alpha = brightness * radialFalloff;
// Skip if alpha is too low
if (alpha < 0.05) continue;
// Determine LED color based on color mode
let fillColor: string;
if (this.colorMode === 'rgb') {
fillColor = `rgba(${r}, ${g}, ${b}, ${alpha})`;
} else {
// Mono mode - cyan
fillColor = `rgba(0, 255, 255, ${alpha})`;
}
// Configure glow effect if enabled
if (this.glow) {
this.ctx.shadowBlur = 4 * animOffset.scale;
this.ctx.shadowColor = fillColor;
} else {
this.ctx.shadowBlur = 0;
}
// Set fill color
this.ctx.fillStyle = fillColor;
// Save context for transformation
this.ctx.save();
// Translate to LED position
this.ctx.translate(ledX, ledY);
// Apply rotation if needed
if (animOffset.rotation !== 0) {
this.ctx.rotate(animOffset.rotation);
}
// Apply scale if needed
if (animOffset.scale !== 1) {
this.ctx.scale(animOffset.scale, animOffset.scale);
}
// Draw the LED circle using the pre-built path
this.ctx.fill(this.ledPath);
// Restore context
this.ctx.restore();
}
}
}
// Reset shadow settings
this.ctx.shadowBlur = 0;
if (this.isHovering) {
this.animationFrame = requestAnimationFrame(this.render);
}
};
public attach(element: HTMLElement): void {
if (!(element instanceof HTMLImageElement)) {
console.error('DotMatrix effect can only be applied to img elements');
return;
}
this.element = element;
const setupEffect = () => {
// Create canvas without DPR scaling
const canvas = document.createElement('canvas');
// Get the displayed dimensions of the image
const rect = element.getBoundingClientRect();
let width = rect.width;
let height = rect.height;
// Fallback to natural dimensions if displayed dimensions are 0
if (width <= 0 || height <= 0) {
width = element.naturalWidth || 300; // Default fallback
height = element.naturalHeight || 200; // Default fallback
console.warn('Using fallback dimensions for canvas:', width, height);
}
// Set canvas size to match the displayed image size exactly (no retina scaling)
canvas.width = width;
canvas.height = height;
// Position canvas directly over the image
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.opacity = '0';
canvas.style.transition = 'opacity 0.3s ease';
canvas.style.pointerEvents = 'none';
canvas.style.backgroundColor = 'transparent';
canvas.dataset.dotMatrixId = this.id;
this.canvas = canvas;
// Get context without scaling
this.ctx = canvas.getContext('2d', {
alpha: true,
willReadFrequently: false
});
if (this.ctx) {
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.imageSmoothingEnabled = false;
}
// Create temp canvas for sampling
this.tempCanvas = document.createElement('canvas');
this.tempCtx = this.tempCanvas.getContext('2d', { alpha: true });
// Only update image data if we have valid dimensions
if (width > 0 && height > 0) {
this.updateImageData();
}
// Create wrapper
let wrapper = this.element!.parentElement;
if (!wrapper || !wrapper.classList.contains('dot-matrix-wrapper')) {
wrapper = document.createElement('div');
wrapper.className = 'dot-matrix-wrapper';
wrapper.style.position = 'relative';
wrapper.style.display = 'inline-block';
this.element!.replaceWith(wrapper);
wrapper.appendChild(this.element!);
}
wrapper.appendChild(canvas);
// Add event listeners
wrapper.addEventListener('mouseenter', this.onMouseEnter);
wrapper.addEventListener('mouseleave', this.onMouseLeave);
wrapper.addEventListener('mousemove', this.onMouseMove);
// Handle resize to re-sample
const resizeObserver = new ResizeObserver(() => {
if (this.canvas && this.element) {
const newRect = this.element.getBoundingClientRect();
let newWidth = newRect.width;
let newHeight = newRect.height;
// Fallback to natural dimensions if needed
if (newWidth <= 0 || newHeight <= 0) {
newWidth = this.element.naturalWidth || 300;
newHeight = this.element.naturalHeight || 200;
}
this.canvas.width = newWidth;
this.canvas.height = newHeight;
// Only update if dimensions are valid
if (newWidth > 0 && newHeight > 0) {
this.updateImageData();
}
}
});
if (this.element) {
resizeObserver.observe(this.element);
}
};
if (element.complete) {
setupEffect();
} else {
element.onload = setupEffect;
}
}
public detach(): void {
if (!this.element) return;
const wrapper = this.element.parentElement;
if (wrapper) {
wrapper.removeEventListener('mouseenter', this.onMouseEnter);
wrapper.removeEventListener('mouseleave', this.onMouseLeave);
wrapper.removeEventListener('mousemove', this.onMouseMove);
}
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
if (this.canvas) {
this.canvas.remove();
this.canvas = null;
this.ctx = null;
}
this.tempCanvas = null;
this.tempCtx = null;
this.imageData = null;
this.element = null;
}
public destroy(): void {
this.detach();
}
// Public setters
public setRadius(radius: number): void {
this.radius = Math.max(10, Math.min(500, radius));
}
public setLedSize(ledSize: number): void {
this.ledSize = Math.max(2, Math.min(20, ledSize));
this.buildLedPath(); // Rebuild the path with new size
}
public setLedSpacing(ledSpacing: number): void {
this.ledSpacing = Math.max(this.ledSize + 1, Math.min(50, ledSpacing));
this.updateImageData(); // Re-sample with new spacing
}
public setGlow(glow: boolean): void {
this.glow = glow;
}
public setSoftEdge(softEdge: number): void {
this.softEdge = Math.max(0, Math.min(100, softEdge));
}
public setScale(scale: number): void {
this.scale = Math.max(0.05, Math.min(0.3, scale));
this.updateImageData(); // Re-sample with new scale
}
public setFadeExp(fadeExp: number): void {
this.fadeExp = Math.max(0.5, Math.min(5, fadeExp));
}
public setColorMode(colorMode: 'mono' | 'rgb'): void {
this.colorMode = colorMode;
}
// Animation methods
public setAnimationType(animationType: 'none' | 'wave' | 'pulse' | 'drift' | 'ripple' | 'rotation'): void {
this.animationType = animationType;
}
public setAnimationSpeed(animationSpeed: number): void {
this.animationSpeed = Math.max(0.1, Math.min(5.0, animationSpeed));
}
public setAnimationIntensity(animationIntensity: number): void {
this.animationIntensity = Math.max(0.1, Math.min(10.0, animationIntensity));
}
public addDriftOffset(x: number, y: number, vx: number, vy: number): void {
this.driftOffsets.push({ x, y, vx, vy });
}
}