Build a Pixel Card Effect in React
Create a card that reveals its content through a pixelated dissolve animation on hover or scroll.
Pixels dissolving into sharp content is a visual trick that never really gets old. It triggers the same satisfaction as a developing photograph, and your brain cannot help but watch it finish. This implementation uses a canvas element and a custom particle system for smooth, controllable results.
The final result
What we are building
A card container with a canvas overlay that fills with pixel particles on hover and dissolves away on mouse leave. Each pixel appears with a slight delay based on its distance from center, creating a radial reveal pattern. The animation respects prefers-reduced-motion.
Setting up
import { useEffect, useRef } from 'react';No animation libraries. This is a raw canvas animation loop with requestAnimationFrame.
Building the component
The Pixel class
Each pixel is an instance of a Pixel class that manages its own state. The appear and disappear methods handle the animation logic frame by frame:
class Pixel {
appear() {
this.isIdle = false;
if (this.counter <= this.delay) {
this.counter += this.counterStep;
return;
}
if (this.size >= this.maxSize) this.isShimmer = true;
if (this.isShimmer) this.shimmer();
else this.size += this.sizeStep;
this.draw();
}
disappear() {
this.isShimmer = false;
this.counter = 0;
if (this.size <= 0) {
this.isIdle = true;
return;
} else this.size -= 0.1;
this.draw();
}
shimmer() {
if (this.size >= this.maxSize) this.isReverse = true;
else if (this.size <= this.minSize) this.isReverse = false;
if (this.isReverse) this.size -= this.speed;
else this.size += this.speed;
}
}The shimmer method makes fully grown pixels pulse slightly, creating a sparkle effect on hover.
Radial delay distribution
The delay for each pixel is set based on its distance from the canvas center. Pixels closer to the center appear first:
const dx = x - width / 2;
const dy = y - height / 2;
const delay = reducedMotion ? 0 : Math.sqrt(dx * dx + dy * dy);The square root of the squared distance is the Euclidean distance. No normalization needed since it is only used as a relative ordering value.
The animation loop
The doAnimate function caps at 60fps and calls either appear or disappear on every pixel each frame. It stops itself when all pixels become idle:
const doAnimate = (fnName: keyof Pixel) => {
animationRef.current = requestAnimationFrame(() => doAnimate(fnName));
const timeNow = performance.now();
const timePassed = timeNow - timePreviousRef.current;
const timeInterval = 1000 / 60;
if (timePassed < timeInterval) return;
timePreviousRef.current = timeNow - (timePassed % timeInterval);
const ctx = canvasRef.current?.getContext('2d');
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
let allIdle = true;
for (let i = 0; i < pixelsRef.current.length; i++) {
const pixel = pixelsRef.current[i];
(pixel as any)[fnName]();
if (!pixel.isIdle) allIdle = false;
}
if (allIdle) cancelAnimationFrame(animationRef.current);
};Built-in variants
Four named presets control the color, gap, and speed:
const VARIANTS = {
default: { activeColor: null, gap: 5, speed: 35, colors: '#f8fafc,#f1f5f9,#cbd5e1', noFocus: false },
blue: { activeColor: '#e0f2fe', gap: 10, speed: 25, colors: '#e0f2fe,#7dd3fc,#0ea5e9', noFocus: false },
yellow: { activeColor: '#fef08a', gap: 3, speed: 20, colors: '#fef08a,#fde047,#eab308', noFocus: false },
pink: { activeColor: '#fecdd3', gap: 6, speed: 80, colors: '#fecdd3,#fda4af,#e11d48', noFocus: true },
};How to use it
<PixelCard variant="blue" className="bg-gray-950">
<div className='absolute inset-0 flex flex-col items-center justify-center gap-2 z-10'>
<h3 className='text-white font-bold'>Project Name</h3>
<p className='text-gray-400 text-sm'>Brief description</p>
</div>
</PixelCard>Children are rendered above the canvas using absolute positioning and a higher z-index.
| Prop | Default | Description |
|------|---------|-------------|
| variant | default | Preset color scheme |
| gap | from variant | Pixel grid spacing |
| speed | from variant | Animation speed (higher = faster) |
| colors | from variant | Comma-separated color list |
| noFocus | from variant | Disable focus-triggered animation |
Key takeaways
- The canvas is sized to match the container using
getBoundingClientRect()and re-initialized on resize via aResizeObserver. This is necessary because canvas dimensions must be set explicitly in pixels. - Calling
cancelAnimationFramewhen all pixels are idle means the loop has zero cost at rest. No ongoing computation when the card is not being interacted with. - The
speedvalue goes throughgetEffectiveSpeedwhich applies a throttle factor of0.001. This converts the user-facing 0-100 range into a float suitable for sub-pixel size changes each frame.