Claude-skill-registry 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.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/canvas-effects" ~/.claude/skills/majiayu000-claude-skill-registry-canvas-effects && rm -rf "$T"
manifest:
skills/data/canvas-effects/SKILL.mdsource content
Canvas Effects Best Practices
Apply when implementing animated visual effects with HTML Canvas.
Setup
Basic Canvas Component Pattern
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)
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
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)
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
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
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
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
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
.grain-canvas { mask-image: url('text-mask.svg'); mask-size: contain; -webkit-mask-image: url('text-mask.svg'); }
Reactive Effects
Cursor Influence Zone
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
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
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
<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)