hover-effects-ts
Version:
A collection of beautiful hover effects for images using canvas
264 lines (217 loc) • 7.56 kB
text/typescript
import { HoverEffect } from '../core/types';
export class PixelHover implements HoverEffect {
private element: HTMLImageElement | null = null;
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private samples: Array<{ x: number; y: number; color: string }> = [];
private cursor = { x: 0, y: 0, active: false };
private animationFrame: number | null = null;
private isSetup = false;
// Configuration
private blockSize: number;
private radius: number;
private readonly softEdge: number;
private readonly fadeExp: number;
constructor(options: { blockSize?: number; radius?: number } = {}) {
// Initialize properties first with defaults
this.blockSize = 16; // Default
this.radius = 130; // Default
// Override with options if provided
if (options.blockSize !== undefined) this.blockSize = options.blockSize;
if (options.radius !== undefined) this.radius = options.radius;
// Now calculate dependent properties
this.softEdge = Math.min(70, this.radius / 2);
this.fadeExp = 1.4;
}
private sampleImage(): void {
if (!this.element || !this.canvas || !this.ctx) return;
// Create offscreen canvas
const off = document.createElement('canvas');
off.width = this.canvas.width;
off.height = this.canvas.height;
const offCtx = off.getContext('2d');
if (!offCtx) return;
// Draw image to offscreen canvas
offCtx.drawImage(this.element, 0, 0, off.width, off.height);
const { data } = offCtx.getImageData(0, 0, off.width, off.height);
// Clear existing samples
this.samples = [];
// Sample pixels
for (let y = 0; y < off.height; y += this.blockSize) {
for (let x = 0; x < off.width; x += this.blockSize) {
const i = (y * off.width + x) * 4;
if (i >= data.length || data[i + 3] < 120) continue; // Skip transparent pixels or out of bounds
this.samples.push({
x,
y,
color: `rgb(${data[i]},${data[i + 1]},${data[i + 2]})`
});
}
}
}
private render = (): void => {
if (!this.canvas || !this.ctx || !this.isSetup) return;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (this.cursor.active) {
this.samples.forEach(s => {
const dist = Math.hypot(s.x - this.cursor.x, s.y - this.cursor.y);
if (dist < this.radius + this.softEdge) {
const base = dist < this.radius ? 1 : 1 - (dist - this.radius) / this.softEdge;
const eased = Math.pow(base, this.fadeExp);
this.ctx!.fillStyle = s.color;
this.ctx!.globalAlpha = eased;
this.ctx!.fillRect(s.x, s.y, this.blockSize, this.blockSize);
}
});
}
this.animationFrame = requestAnimationFrame(this.render);
};
private onMouseMove = (e: MouseEvent): void => {
if (!this.element || !this.canvas) return;
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
this.cursor.x = (e.clientX - rect.left) * scaleX;
this.cursor.y = (e.clientY - rect.top) * scaleY;
this.cursor.active = true;
if (!this.canvas.style.opacity) {
this.canvas.style.opacity = '1';
}
};
private onMouseEnter = (): void => {
if (this.canvas) {
this.canvas.style.opacity = '1';
}
};
private onMouseLeave = (): void => {
if (this.canvas) {
this.canvas.style.opacity = '0';
this.cursor.active = false;
}
};
public attach(element: HTMLElement): void {
if (!(element instanceof HTMLImageElement)) {
return;
}
this.element = element;
const setupEffect = () => {
// Create canvas at the same size as the image
const canvas = document.createElement('canvas');
canvas.width = element.naturalWidth;
canvas.height = element.naturalHeight;
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
canvas.style.opacity = '0';
canvas.style.transition = 'opacity 0.25s ease';
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
// Create wrapper if needed
let wrapper = element.parentElement;
if (!wrapper || !wrapper.classList.contains('pixel-wrapper')) {
wrapper = document.createElement('div');
wrapper.className = 'pixel-wrapper';
wrapper.style.position = 'relative';
wrapper.style.display = 'inline-block';
element.replaceWith(wrapper);
wrapper.appendChild(element);
}
// Add canvas to wrapper
wrapper.appendChild(canvas);
// Sample image and create pixels
this.sampleImage();
this.isSetup = true;
// Start animation
this.animationFrame = requestAnimationFrame(this.render);
// Add event listeners
wrapper.addEventListener('mousemove', this.onMouseMove);
wrapper.addEventListener('mouseenter', this.onMouseEnter);
wrapper.addEventListener('mouseleave', this.onMouseLeave);
};
if (element.complete) {
setupEffect();
} else {
element.onload = setupEffect;
}
}
public detach(): void {
if (!this.element) return;
// Remove event listeners
const wrapper = this.element.parentElement;
if (wrapper) {
wrapper.removeEventListener('mousemove', this.onMouseMove);
wrapper.removeEventListener('mouseenter', this.onMouseEnter);
wrapper.removeEventListener('mouseleave', this.onMouseLeave);
}
// Stop animation
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
// Remove canvas
this.canvas?.remove();
// Reset state
this.samples = [];
this.isSetup = false;
}
public destroy(): void {
this.detach();
this.element = null;
this.canvas = null;
this.ctx = null;
}
public setBlockSize(blockSize: number): void {
if (blockSize === this.blockSize) {
return;
}
this.blockSize = blockSize;
if (this.isSetup) {
// Force canvas clear
if (this.ctx && this.canvas) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
// Store cursor state
const cursorWasActive = this.cursor.active;
// Temporarily deactivate cursor to prevent rendering during sampling
this.cursor.active = false;
// Recreate samples with new block size
this.sampleImage();
// Restore cursor state
this.cursor.active = cursorWasActive;
// Force immediate render with new samples
if (this.cursor.active) {
this.render();
}
} else {
}
}
public setRadius(radius: number): void {
this.radius = radius;
}
// Getter methods for debugging
public getBlockSize(): number {
return this.blockSize;
}
public getRadius(): number {
return this.radius;
}
public getSamplesCount(): number {
return this.samples.length;
}
public getDebugInfo(): object {
return {
blockSize: this.blockSize,
radius: this.radius,
samplesCount: this.samples.length,
isSetup: this.isSetup,
cursorActive: this.cursor.active,
canvasSize: this.canvas ? {
width: this.canvas.width,
height: this.canvas.height
} : null
};
}
}