pixel loaders are cool
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:
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:
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.
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:
- Check if 80ms passed since the last activation
- If yes, pick a random neighbor of the last activated cell and light it up
- Decay all cells by subtracting from their brightness
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:
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:
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:
- 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:
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:
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:
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:
- Refs for timing:
lastActivatedRefandlastActivationTimeRefdon't trigger re-renders when they change - Single state update: one
setCellStatescall 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.