Agent skill
canvas-effects
Use when implementing Canvas-based visual effects like noise, grain, particles, or animated textures. Applies performance best practices for animation loops and pixel manipulation.
Stars
163
Forks
31
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/canvas-effects
SKILL.md
Canvas Effects Best Practices
Apply when implementing animated visual effects with HTML Canvas.
Setup
Basic Canvas Component Pattern
typescript
interface CanvasEffectConfig {
density: number; // 0-1
speed: number; // animation speed multiplier
color: string; // hex or rgb
enabled: boolean;
}
class CanvasEffect {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private animationId: number | null = null;
private config: CanvasEffectConfig;
constructor(canvas: HTMLCanvasElement, config: CanvasEffectConfig) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.config = config;
this.resize();
}
resize() {
const dpr = window.devicePixelRatio || 1;
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * dpr;
this.canvas.height = rect.height * dpr;
this.ctx.scale(dpr, dpr);
}
start() {
if (!this.config.enabled) return;
const loop = () => {
this.render();
this.animationId = requestAnimationFrame(loop);
};
loop();
}
stop() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
destroy() {
this.stop();
// cleanup resources
}
private render() {
// implement in subclass
}
}
Noise/Grain Effect
ImageData Manipulation (Fast)
typescript
private render() {
const imageData = this.ctx.createImageData(
this.canvas.width,
this.canvas.height
);
const data = imageData.data;
const density = this.config.density;
for (let i = 0; i < data.length; i += 4) {
if (Math.random() > density) continue;
const noise = Math.random() * 255;
data[i] = noise; // R
data[i + 1] = noise; // G
data[i + 2] = noise; // B
data[i + 3] = 20; // A (low opacity)
}
this.ctx.putImageData(imageData, 0, 0);
}
Colored Grain
typescript
private render() {
const { r, g, b } = hexToRgb(this.config.color);
// ... in loop:
data[i] = r + (Math.random() - 0.5) * 50;
data[i + 1] = g + (Math.random() - 0.5) * 50;
data[i + 2] = b + (Math.random() - 0.5) * 50;
data[i + 3] = Math.random() * 30;
}
Performance
Offscreen Canvas (Critical for Complex Effects)
typescript
private offscreen: OffscreenCanvas;
private offscreenCtx: OffscreenCanvasRenderingContext2D;
constructor() {
this.offscreen = new OffscreenCanvas(width, height);
this.offscreenCtx = this.offscreen.getContext('2d')!;
}
private render() {
// Draw to offscreen first
this.renderToOffscreen();
// Then copy to visible canvas
this.ctx.drawImage(this.offscreen, 0, 0);
}
Throttle Render Updates
typescript
private lastRender = 0;
private targetFPS = 30; // grain doesn't need 60fps
private frameInterval = 1000 / this.targetFPS;
private loop = (timestamp: number) => {
const delta = timestamp - this.lastRender;
if (delta >= this.frameInterval) {
this.render();
this.lastRender = timestamp - (delta % this.frameInterval);
}
this.animationId = requestAnimationFrame(this.loop);
};
Reduce Resolution for Performance
typescript
resize() {
const dpr = Math.min(window.devicePixelRatio, 1.5); // cap at 1.5x
// or for grain effects, even lower:
const dpr = 1; // grain doesn't need retina
}
Visibility Check
typescript
private observer: IntersectionObserver;
constructor() {
this.observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
this.start();
} else {
this.stop();
}
},
{ threshold: 0 }
);
this.observer.observe(this.canvas);
}
Text Masking
Clip Grain to Text Shape
typescript
private renderGrainInText(text: string) {
// 1. Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 2. Draw text as clip path
this.ctx.save();
this.ctx.font = 'bold 120px Unbounded';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
// Create clipping region from text
this.ctx.beginPath();
this.ctx.rect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.clip();
// Use globalCompositeOperation for text mask
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.fillStyle = 'white';
this.ctx.fillText(text, this.canvas.width / 2, this.canvas.height / 2);
// 3. Draw grain only where text exists
this.ctx.globalCompositeOperation = 'source-atop';
this.renderGrain();
this.ctx.restore();
}
Alternative: CSS Mask
css
.grain-canvas {
mask-image: url('text-mask.svg');
mask-size: contain;
-webkit-mask-image: url('text-mask.svg');
}
Reactive Effects
Cursor Influence Zone
typescript
private cursorX = 0;
private cursorY = 0;
private influenceRadius = 150;
handleMouseMove = (e: MouseEvent) => {
const rect = this.canvas.getBoundingClientRect();
this.cursorX = e.clientX - rect.left;
this.cursorY = e.clientY - rect.top;
};
private getInfluence(x: number, y: number): number {
const distance = Math.hypot(x - this.cursorX, y - this.cursorY);
if (distance > this.influenceRadius) return 0;
// Exponential falloff
return Math.pow(1 - distance / this.influenceRadius, 2);
}
Reduced Motion
Respect User Preference
typescript
private prefersReducedMotion =
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
start() {
if (this.prefersReducedMotion) {
this.renderStatic(); // Single frame, no animation
return;
}
// ... normal animation loop
}
Cleanup (Critical)
Always Clean Up
typescript
destroy() {
this.stop();
this.observer?.disconnect();
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('mousemove', this.handleMouseMove);
this.canvas.width = 0;
this.canvas.height = 0;
}
Astro Integration
astro
<script>
import { CanvasGrain } from './CanvasGrain';
const canvas = document.querySelector('canvas');
const effect = new CanvasGrain(canvas, config);
effect.start();
// Cleanup on page navigation (View Transitions)
document.addEventListener('astro:before-swap', () => {
effect.destroy();
});
</script>
Avoid
- Running at 60fps when 30fps suffices (grain, noise)
- Full DPR on texture effects (wastes GPU)
- Animating when not visible
- Forgetting cleanup on unmount
- Creating new ImageData every frame (reuse it)
- Large influence radius calculations per-pixel (use grid sampling)
Didn't find tool you were looking for?