Pixel Spinner
React + Tailwind
import { useEffect, useState } from 'react';
const css = `
.loader-pixel-spinner {
display: grid;
grid-template-columns: repeat(11, 7px);
grid-auto-rows: 7px;
gap: 1px;
}
.loader-pixel-spinner__px { background: transparent; }
.loader-pixel-spinner__px[data-ring] { background: light-dark(#ebedf0, #232529); }
.loader-pixel-spinner__px[data-lit='3'] { background: light-dark(#373a40, #f1f2f3); }
.loader-pixel-spinner__px[data-lit='2'] { background: light-dark(#50535b, #c6c8cd); }
.loader-pixel-spinner__px[data-lit='1'] { background: light-dark(#6d7179, #7d8087); }
`;
const N = 11;
const C = (N - 1) / 2;
// The ring: every pixel whose centre lies between radius 3.4 and 5.1, ordered by angle.
const RING = Array.from({ length: N * N }, (_, i) => i)
.filter((i) => {
const d = Math.hypot((i % N) - C, Math.floor(i / N) - C);
return d > 3.4 && d < 5.1;
})
.sort((a, b) => {
const ang = (i: number) => Math.atan2(Math.floor(i / N) - C, (i % N) - C);
return ang(a) - ang(b);
});
const ON_RING = new Set(RING);
const TAIL = 5;
export function PixelSpinnerLoader() {
const [head, setHead] = useState(0);
useEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const id = window.setInterval(() => setHead((h) => (h + 1) % RING.length), 55);
return () => clearInterval(id);
}, []);
// Brightness by distance behind the head: 3 for the head, fading to 1 at the tail.
const lit = new Map<number, number>();
for (let k = 0; k < TAIL; k++) lit.set(RING[(head - k + RING.length) % RING.length], k < 2 ? 3 : k < 4 ? 2 : 1);
return (
<>
<style>
{css}
</style>
<div className="loader-pixel-spinner" role="status" aria-label="Loading">
{Array.from({ length: N * N }, (_, i) => (
<span
key={i}
className="loader-pixel-spinner__px"
data-ring={ON_RING.has(i) || undefined}
data-lit={lit.get(i)}
aria-hidden="true"
/>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initLoaderPixelSpinner(el) on the mounted element instead.
HTML
<div class="loader-pixel-spinner" role="status" aria-label="Loading">
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" data-lit="3" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" data-lit="2" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" data-lit="3" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" data-lit="2" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" data-lit="1" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" data-ring="true" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
<span class="loader-pixel-spinner__px" aria-hidden="true"></span>
</div>CSS
.loader-pixel-spinner {
display: grid;
grid-template-columns: repeat(11, 7px);
grid-auto-rows: 7px;
gap: 1px;
}
.loader-pixel-spinner__px { background: transparent; }
.loader-pixel-spinner__px[data-ring] { background: light-dark(#ebedf0, #232529); }
.loader-pixel-spinner__px[data-lit='3'] { background: light-dark(#373a40, #f1f2f3); }
.loader-pixel-spinner__px[data-lit='2'] { background: light-dark(#50535b, #c6c8cd); }
.loader-pixel-spinner__px[data-lit='1'] { background: light-dark(#6d7179, #7d8087); }JavaScript
function initLoaderPixelSpinner(root) {
var px = root.querySelectorAll('.loader-pixel-spinner__px');
var N = 11;
if (px.length !== N * N) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var C = (N - 1) / 2;
// The ring: every pixel whose centre lies between radius 3.4 and 5.1, ordered by angle.
var ring = [];
for (var i = 0; i < N * N; i++) {
var d = Math.hypot((i % N) - C, Math.floor(i / N) - C);
if (d > 3.4 && d < 5.1) ring.push(i);
}
function ang(i) { return Math.atan2(Math.floor(i / N) - C, (i % N) - C); }
ring.sort(function (a, b) { return ang(a) - ang(b); });
var TAIL = 5;
var head = 0;
var timer = setInterval(function () {
head = (head + 1) % ring.length;
px.forEach(function (p) { p.removeAttribute('data-lit'); });
// Brightness by distance behind the head: 3 for the head, fading to 1 at the tail.
for (var k = 0; k < TAIL; k++) {
px[ring[(head - k + ring.length) % ring.length]].setAttribute('data-lit', k < 2 ? '3' : k < 4 ? '2' : '1');
}
}, 55);
return function () { clearInterval(timer); };
}
document.querySelectorAll('.loader-pixel-spinner').forEach(initLoaderPixelSpinner);Goal: Create a "Pixel Spinner" loader.
Visual behavior: A chunky 8-bit spinner: a ring of square pixels with a bright segment and a fading tail stepping round it, one pixel at a time.
Technique:
- An 11 x 11 grid of square cells; the ring is every cell whose centre lies between radius 3.4 and 5.1, sorted by angle.
- Every 55ms the head moves one ring cell on; the head and the cell behind it are brightest, then two mid cells and a faint tail (a data-lit level per cell).
- Stepping one whole pixel at a time, with no easing, is what makes it read as 8-bit.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the segment stays still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Pixel Spinner" loader.
Visual behavior: A chunky 8-bit spinner: a ring of square pixels with a bright segment and a fading tail stepping round it, one pixel at a time.
Technique:
- An 11 x 11 grid of square cells; the ring is every cell whose centre lies between radius 3.4 and 5.1, sorted by angle.
- Every 55ms the head moves one ring cell on; the head and the cell behind it are brightest, then two mid cells and a faint tail (a data-lit level per cell).
- Stepping one whole pixel at a time, with no easing, is what makes it read as 8-bit.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the segment stays still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A chunky 8-bit spinner: a ring of square pixels with a bright segment and a fading tail stepping round it, one pixel at a time.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- continuous, loop
- Techniques
- interval, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the segment stays still.