blogprojectshajspace
Back to blog

pixel loaders are cool

·5 min read

I hate spinners. The same circular motion, over and over. Progress bars are fine when you actually know the progress, but most of the time you don't. Pulsing dots? Sure, if you want your app to feel like it's from 2015.

So I made this instead:

pixel-loader

It's a 3×3 grid where cells light up and fade. The trick is that each cell can only activate cells next to it. Simple constraint, but it makes the movement feel weirdly organic.

how it works

Nine cells, indexed 0-8:

adjacency-visualization
0
1
2
3
4
5
6
7
8

Cell indices in a 3×3 grid. Each cell can only activate its adjacent neighbors.

Each cell has a list of neighbors it can activate. Cell 4 (the center) can reach everyone. Corner cells only reach 3 neighbors. This creates natural flow patterns instead of random noise.

typescript
const ADJACENCY: Record<number, number[]> = {
  0: [1, 3, 4],
  1: [0, 2, 3, 4, 5],
  2: [1, 4, 5],
  3: [0, 1, 4, 6, 7],
  4: [0, 1, 2, 3, 5, 6, 7, 8],
  5: [1, 2, 4, 7, 8],
  6: [3, 4, 7],
  7: [3, 4, 5, 6, 8],
  8: [4, 5, 7],
};

the animation

The component tracks brightness for each cell (0 to 1) and runs a loop with requestAnimationFrame. Every frame:

  1. Check if 80ms passed since the last activation
  2. If yes, pick a random neighbor of the last activated cell and light it up
  3. Decay all cells by subtracting from their brightness
typescript
const ACTIVATION_INTERVAL = 80;
const DECAY_RATE = 0.025;

function PixelLoader({ size = 24 }) {
  const [cellStates, setCellStates] = useState<{ brightness: number }[]>(
    Array(9).fill(null).map(() => ({ brightness: 0 }))
  );
  const lastActivatedRef = useRef(-1);
  const lastActivationTimeRef = useRef(0);

  const animate = useCallback((timestamp: number) => {
    const timeSinceLastActivation = timestamp - lastActivationTimeRef.current;
    const shouldActivate = timeSinceLastActivation >= ACTIVATION_INTERVAL;

    setCellStates(prev => {
      let activatedIndex = -1;

      if (shouldActivate) {
        const lastIdx = lastActivatedRef.current;
        const neighbors = lastIdx >= 0 
          ? ADJACENCY[lastIdx] 
          : [0, 1, 2, 3, 4, 5, 6, 7, 8];
        const eligible = neighbors.filter(i => prev[i].brightness < 0.5);

        if (eligible.length > 0) {
          activatedIndex = eligible[Math.floor(Math.random() * eligible.length)];
          lastActivatedRef.current = activatedIndex;
          lastActivationTimeRef.current = timestamp;
        }
      }

      return prev.map((cell, i) => ({
        brightness: i === activatedIndex 
          ? 1 
          : Math.max(0, cell.brightness - DECAY_RATE)
      }));
    });

    requestAnimationFrame(animate);
  }, []);

  useEffect(() => {
    const frame = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(frame);
  }, [animate]);

  // render...
}

The brightness < 0.5 filter stops cells from re-activating while they're still bright. Otherwise it looks frantic. When no neighbors are eligible, it falls back to any cell that's dim enough.

making it look good

Map brightness to opacity, but keep a minimum so cells never vanish:

typescript
const cellSize = size / 3;

return (
  <div className="grid grid-cols-3" style={{ width: size, height: size }}>
    {cellStates.map((cell, i) => (
      <div
        key={i}
        className="bg-white"
        style={{
          width: cellSize,
          height: cellSize,
          opacity: 0.1 + cell.brightness * 0.7,
          transition: "opacity 50ms linear",
        }}
      />
    ))}
  </div>
);

The 10% floor keeps the grid visible as a cohesive shape. Without it, inactive cells disappear and the whole thing looks broken.

adding glow

Plain opacity changes look flat. Adding a glow that scales with brightness makes bright cells pop:

typescript
style={{
  width: cellSize,
  height: cellSize,
  opacity: 0.1 + cell.brightness * 0.7,
  boxShadow: cell.brightness > 0.3
    ? `0 0 ${cellSize * cell.brightness}px rgba(255, 255, 255, ${cell.brightness * 0.5})`
    : 'none',
  transition: "opacity 50ms linear, box-shadow 50ms linear",
}}

The glow only kicks in above 0.3 brightness so dim cells don't have weird halos. The radius scales with cellSize * brightness so it's proportional to the loader size. The alpha also scales with brightness so the glow fades smoothly.

variations

Swap the adjacency rules and you get completely different vibes:

pixel-loader-variants
Perimeter Loop
Cardinal Loop
All Adjacent
Cardinal Only
Perimeter Random
  • Perimeter Loop: deterministic loop around all 8 outer cells (0→1→2→5→8→7→6→3), very predictable, kind of hypnotic
  • Cardinal Loop: same idea but only the four side positions (1→5→7→3), faster and more minimal
  • All Adjacent: the default, organic and unpredictable
  • Cardinal Only: random but only up/down/left/right, no diagonals, more structured
  • Perimeter Random: random movement around the outer ring, center never lights up

For deterministic sequences, cycle through a fixed array instead of picking randomly:

typescript
const CLOCKWISE_PERIMETER = [0, 1, 2, 5, 8, 7, 6, 3];
let currentIndex = 0;

function getNextCell(): number {
  const cell = CLOCKWISE_PERIMETER[currentIndex];
  currentIndex = (currentIndex + 1) % CLOCKWISE_PERIMETER.length;
  return cell;
}

Cardinal-only adjacency removes diagonal connections:

typescript
const ADJACENCY_CARDINAL: Record<number, number[]> = {
  0: [1, 3],
  1: [0, 2, 4],
  2: [1, 5],
  3: [0, 4, 6],
  4: [1, 3, 5, 7],
  5: [2, 4, 8],
  6: [3, 7],
  7: [4, 6, 8],
  8: [5, 7],
};

ssr gotcha

requestAnimationFrame doesn't exist on the server. You need to wait for mount:

typescript
const [mounted, setMounted] = useState(false);

useEffect(() => {
  setMounted(true);
}, []);

if (!mounted) {
  return <StaticPlaceholder />;
}

Render the grid at 10% opacity until hydration. Same dimensions, just frozen. No layout shift.

performance

Two things keep it light:

  1. Refs for timing: lastActivatedRef and lastActivationTimeRef don't trigger re-renders when they change
  2. Single state update: one setCellStates call per frame, not nine individual updates

Uses about 0.1% CPU on my M2 Air. The browser compositor handles the opacity and box-shadow transitions.

when to use it

Good for:

  • Short waits where a spinner feels too corporate
  • Personal projects where you want some character
  • Unknown duration operations

Bad for:

  • Long operations (users need actual progress)
  • Enterprise software (they expect boring)
  • Anywhere playfulness would be weird

The best loading state is no loading state. But if you have to show something, constrained randomness beats a spinning circle.

Back to blog