Dirck Mulder
Components||4 min read

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

tsx
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:

tsx
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:

tsx
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:

tsx
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:

tsx
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

tsx
<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 a ResizeObserver. This is necessary because canvas dimensions must be set explicitly in pixels.
  • Calling cancelAnimationFrame when all pixels are idle means the loop has zero cost at rest. No ongoing computation when the card is not being interacted with.
  • The speed value goes through getEffectiveSpeed which applies a throttle factor of 0.001. This converts the user-facing 0-100 range into a float suitable for sub-pixel size changes each frame.