Bitmap
Chunky 8-bit pixel loader — a hard-edged grid that fills cell-by-cell. Determinate bar (with a pulsing frontier) or an indeterminate marching band, plus a crisp bitmap-radar spinner that snaps cell-to-cell via steps(). Zero dependencies, SSR-safe, role=progressbar with aria-valuenow; collapses to a designed static frame under reduced motion.
cssfree
import * as React from "react";
import { cn } from "@/lib/utils";
export interface BitmapProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Which loader to draw.
* - `bar` (default): a chunky pixel-grid progress bar that fills cell-by-cell.
* Give it a `value` (0–100) for a determinate fill; omit it for an
* indeterminate lit band that marches across and loops.
* - `spinner`: a crisp bitmap radar — a square grid of pixels with a bright
* arc sweeping around a dim disc, snapping cell-to-cell like an 8-bit
* throbber.
* @default "bar"
*/
variant?: "bar" | "spinner";
/**
* Determinate progress, `0`–`100`. When set, the `bar` fills to this
* fraction and reports `aria-valuenow`; when omitted (or `undefined`) the
* loader is indeterminate. Ignored by `spinner`.
* @default undefined
*/
value?: number;
/** Number of columns in the `bar` grid. @default 24 */
cells?: number;
/** Number of rows (thickness) in the `bar` grid. @default 3 */
rows?: number;
/** Grid dimension of the `spinner` (forced odd so it has a true centre). @default 7 */
grid?: number;
/** Edge length of a single pixel cell, in px. @default 10 */
size?: number;
/** Gap between cells, in px — the dark mortar of the grid. @default 2 */
gap?: number;
/** Corner radius of each cell in px (0 = hard bitmap edge). @default 1 */
radius?: number;
/** Animation speed multiplier. 1 = default, 2 = twice as fast. @default 1 */
speed?: number;
/** Freeze on a designed, legible resting frame. @default false */
paused?: boolean;
/** Lit-pixel color. @default "#e8ecf5" */
onColor?: string;
/** Unlit-pixel (track) color. @default "#22252e" */
offColor?: string;
/**
* Bloom color of a lit pixel. A faint cool halo by default; set equal to
* `onColor` for a flat, glow-free bitmap. @default "#9fd8ff"
*/
glowColor?: string;
/** Accessible label announced while loading. @default "Loading" */
label?: string;
/** Render a blocky, marching label row beneath the grid. @default false */
showLabel?: boolean;
/** Text of the marching label (uppercased, letter-spaced). @default "LOADING" */
labelText?: string;
}
// Pure-CSS bitmap loader. Each pixel is an "off" cell holding an absolutely
// positioned "lit" overlay; only the overlay's opacity animates, so the grid
// (the retro mortar) never moves — crisp, no anti-alias, no layout thrash.
// Every animated overlay also carries an inline --bm-rest (its resting
// opacity), so `paused` and `prefers-reduced-motion` collapse to a designed,
// legible frame — a half-filled bar / a lit arc — never a blank grid. Browsers
// already throttle CSS animations offscreen and on hidden tabs, so no JS
// observer is needed to pause.
const STYLE = `
[data-crucible="bitmap"] .bm-grid { display: grid; }
[data-crucible="bitmap"] .bm-cell {
position: relative;
background-color: var(--bm-off);
border-radius: var(--bm-radius);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
[data-crucible="bitmap"] .bm-lit {
position: absolute;
inset: 0;
border-radius: var(--bm-radius);
background-color: var(--bm-on);
box-shadow: 0 0 4px 0 color-mix(in oklab, var(--bm-glow) 70%, transparent),
inset 0 0 0 1px color-mix(in oklab, var(--bm-on) 60%, #ffffff);
opacity: var(--bm-o, 0);
will-change: opacity;
}
@keyframes crucible-bitmap-march {
0% { opacity: 1; }
30% { opacity: 0.1; }
100% { opacity: 0.1; }
}
@keyframes crucible-bitmap-sweep {
0% { opacity: 1; }
24% { opacity: 0.12; }
100% { opacity: 0.12; }
}
@keyframes crucible-bitmap-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes crucible-bitmap-dot {
0%, 100% { opacity: 0.18; }
50% { opacity: 1; }
}
[data-crucible="bitmap"][data-anim="march"]:not([data-paused="true"]) .bm-lit {
animation: crucible-bitmap-march var(--bm-dur) steps(6, end) infinite;
}
[data-crucible="bitmap"][data-anim="sweep"]:not([data-paused="true"]) .bm-lit {
animation: crucible-bitmap-sweep var(--bm-dur) steps(7, end) infinite;
}
[data-crucible="bitmap"] .bm-frontier { opacity: 1; }
[data-crucible="bitmap"]:not([data-paused="true"]) .bm-frontier {
animation: crucible-bitmap-pulse calc(var(--bm-dur) * 0.5) ease-in-out infinite;
}
[data-crucible="bitmap"] .bm-dot {
width: var(--bm-dotsize);
height: var(--bm-dotsize);
background-color: var(--bm-on);
border-radius: var(--bm-radius);
box-shadow: 0 0 4px 0 color-mix(in oklab, var(--bm-glow) 60%, transparent);
opacity: 0.18;
}
[data-crucible="bitmap"]:not([data-paused="true"]) .bm-dot {
animation: crucible-bitmap-dot calc(var(--bm-dur) * 0.75) steps(3, end) infinite;
}
/* Paused → collapse animated overlays to their resting pose. */
[data-crucible="bitmap"][data-paused="true"] .bm-lit { animation: none !important; opacity: var(--bm-rest, 0) !important; }
[data-crucible="bitmap"][data-paused="true"] .bm-frontier { animation: none !important; }
[data-crucible="bitmap"][data-paused="true"] .bm-dot { animation: none !important; opacity: 1 !important; }
/* Reduced motion → same designed static frame, no motion. */
@media (prefers-reduced-motion: reduce) {
[data-crucible="bitmap"] .bm-lit { animation: none !important; opacity: var(--bm-rest, 0) !important; }
[data-crucible="bitmap"] .bm-frontier { animation: none !important; }
[data-crucible="bitmap"] .bm-dot { animation: none !important; opacity: 1 !important; }
}
`;
const TAU = Math.PI * 2;
// A single pixel: off base + a lit overlay whose opacity is driven by --bm-o
// (static) and, when animated, restores to --bm-rest under paused/reduced.
function Cell({
o,
rest,
frontier,
animated,
delay,
}: {
o?: number;
rest: number;
frontier?: boolean;
animated?: boolean;
delay?: string;
}) {
const litStyle: React.CSSProperties = {
"--bm-rest": rest,
...(o !== undefined ? { "--bm-o": o } : null),
...(animated ? { animationDelay: delay } : null),
} as React.CSSProperties;
return (
<span className="bm-cell" aria-hidden>
<span className={cn("bm-lit", frontier && "bm-frontier")} style={litStyle} />
</span>
);
}
/**
* Bitmap — a chunky 8-bit pixel loader. `bar` is a grid of hard-edged cells
* that fill cell-by-cell: pass a `value` for a determinate fill (with a
* pulsing frontier column) or omit it for an indeterminate lit band that
* marches across and loops. `spinner` is a crisp bitmap radar — a square pixel
* grid with a bright arc sweeping around a dim disc, snapping cell-to-cell via
* `steps()` for a true retro throbber, no anti-alias in sight.
*
* Zero dependencies, SSR-safe, GPU-light (only pixel opacity animates). Sizes,
* grid dimensions, colors, glow, and speed are all themeable; the region
* carries `role="progressbar"` with `aria-valuenow` when determinate. Under
* `paused` or reduced motion it collapses to a designed resting frame — a
* half-filled bar, a lit arc — never a blank grid.
*/
export function Bitmap({
variant = "bar",
value,
cells = 24,
rows = 3,
grid = 7,
size = 10,
gap = 2,
radius = 1,
speed = 1,
paused = false,
onColor = "#e8ecf5",
offColor = "#22252e",
glowColor = "#9fd8ff",
label = "Loading",
showLabel = false,
labelText = "LOADING",
className,
style,
...rest
}: BitmapProps) {
const cols = Math.max(3, Math.min(Math.round(cells), 80));
const rowCount = Math.max(1, Math.min(Math.round(rows), 12));
// Force odd so the spinner has a true single-cell centre.
const gridN = (() => {
const n = Math.max(3, Math.min(Math.round(grid), 21));
return n % 2 === 0 ? n + 1 : n;
})();
const duration = `${(1.6 / Math.max(speed, 0.05)).toFixed(3)}s`;
const determinate = variant === "bar" && value !== undefined && Number.isFinite(value);
const clamped = determinate ? Math.max(0, Math.min(100, value as number)) : 0;
const filledCols = determinate ? Math.round((clamped / 100) * cols) : 0;
const vars = {
"--bm-on": onColor,
"--bm-off": offColor,
"--bm-glow": glowColor,
"--bm-radius": `${Math.max(0, radius)}px`,
"--bm-dur": duration,
"--bm-dotsize": `${Math.max(4, Math.round(size * 0.7))}px`,
...style,
} as React.CSSProperties;
const gridStyle: React.CSSProperties = { gap };
// --- BAR ---------------------------------------------------------------
let body: React.ReactNode = null;
let animMode: "march" | "sweep" | undefined;
if (variant === "bar") {
animMode = determinate ? undefined : "march";
const restBand = Math.round(cols * 0.6); // frozen "loading" frame
body = (
<div
className="bm-grid"
style={{
...gridStyle,
gridTemplateColumns: `repeat(${cols}, ${size}px)`,
gridTemplateRows: `repeat(${rowCount}, ${size}px)`,
gridAutoFlow: "column",
}}
>
{Array.from({ length: cols }, (_, x) =>
Array.from({ length: rowCount }, (_, y) => {
if (determinate) {
const on = x < filledCols;
const isFrontier = on && x === filledCols - 1;
return (
<Cell
key={`${x}-${y}`}
o={on ? 1 : 0}
rest={on ? 1 : 0}
frontier={isFrontier}
/>
);
}
// Indeterminate: every column marches, delayed by its position, so
// a lit band sweeps left→right. Resting frame = first 60% lit.
return (
<Cell
key={`${x}-${y}`}
rest={x < restBand ? 1 : 0.1}
animated
delay={`${(-(x / cols) * (1.6 / Math.max(speed, 0.05))).toFixed(3)}s`}
/>
);
})
)}
</div>
);
} else {
// --- SPINNER ---------------------------------------------------------
animMode = "sweep";
const c = (gridN - 1) / 2;
const discR = c + 0.55; // include the near-round disc, drop the corners
body = (
<div
className="bm-grid"
style={{
...gridStyle,
gridTemplateColumns: `repeat(${gridN}, ${size}px)`,
gridTemplateRows: `repeat(${gridN}, ${size}px)`,
}}
>
{Array.from({ length: gridN }, (_, y) =>
Array.from({ length: gridN }, (_, x) => {
const dx = x - c;
const dy = y - c;
const dist = Math.hypot(dx, dy);
// Centre + corners stay dark, so the arc sweeps a clean disc.
if (dist < 0.9 || dist > discR) {
return (
<span key={`${x}-${y}`} className="bm-cell" aria-hidden>
<span className="bm-lit" style={{ "--bm-rest": 0 } as React.CSSProperties} />
</span>
);
}
// Angle, 0 at top, increasing clockwise.
const angle = ((Math.atan2(dx, -dy) + TAU) % TAU) / TAU; // 0..1
const delay = `${(-angle * (1.6 / Math.max(speed, 0.05))).toFixed(3)}s`;
// Resting comet: brightest at the head, fading over ~110°.
const headDist = Math.min(angle, 1 - angle) * 2; // 0 at head, 1 opposite
const rest = Math.max(0.14, 1 - headDist * 2.6);
return (
<Cell
key={`${x}-${y}`}
rest={Number(rest.toFixed(3))}
animated
delay={delay}
/>
);
})
)}
</div>
);
}
return (
<div
{...rest}
data-crucible="bitmap"
data-anim={animMode}
data-paused={paused ? "true" : undefined}
role={determinate ? "progressbar" : "status"}
aria-label={label}
aria-busy={determinate ? clamped < 100 : true}
aria-valuemin={determinate ? 0 : undefined}
aria-valuemax={determinate ? 100 : undefined}
aria-valuenow={determinate ? Math.round(clamped) : undefined}
className={cn("relative inline-flex flex-col items-center gap-3", className)}
style={vars}
>
<style>{STYLE}</style>
{body}
{showLabel && (
<div
aria-hidden
className="flex items-center"
style={{ gap: `${Math.max(3, gap * 2)}px` }}
>
<span
className="font-mono uppercase leading-none"
style={{
color: onColor,
fontSize: `${Math.max(8, Math.round(size * 0.9))}px`,
letterSpacing: `${Math.max(2, Math.round(size * 0.35))}px`,
}}
>
{labelText}
</span>
<span className="flex items-center" style={{ gap: `${Math.max(2, gap)}px` }}>
{[0, 1, 2].map((i) => (
<span
key={i}
className="bm-dot"
style={{ animationDelay: `${(-(i / 3) * (0.75 * (1.6 / Math.max(speed, 0.05)))).toFixed(3)}s` }}
/>
))}
</span>
</div>
)}
<span className="sr-only">{label}</span>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/bitmapProps
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "bar" | "spinner" | "bar" | Which loader to draw. - bar (default): a chunky pixel-grid progress bar that fills cell-by-cell. Give it a value (0–100) for a determinate fill; omit it for an indeterminate lit band that marches across and loops. - spinner: a crisp bitmap radar — a square grid of pixels with a bright arc sweeping around a dim disc, snapping cell-to-cell like an 8-bit throbber. |
| value | number | undefined | Determinate progress, 0–100. When set, the bar fills to this fraction and reports aria-valuenow; when omitted (or undefined) the loader is indeterminate. Ignored by spinner. |
| cells | number | 24 | Number of columns in the bar grid. |
| rows | number | 3 | Number of rows (thickness) in the bar grid. |
| grid | number | 7 | Grid dimension of the spinner (forced odd so it has a true centre). |
| size | number | 10 | Edge length of a single pixel cell, in px. |
| gap | number | 2 | Gap between cells, in px — the dark mortar of the grid. |
| radius | number | 1 | Corner radius of each cell in px (0 = hard bitmap edge). |
| speed | number | 1 | Animation speed multiplier. 1 = default, 2 = twice as fast. |
| paused | boolean | false | Freeze on a designed, legible resting frame. |
| onColor | string | "#e8ecf5" | Lit-pixel color. |
| offColor | string | "#22252e" | Unlit-pixel (track) color. |
| glowColor | string | "#9fd8ff" | Bloom color of a lit pixel. A faint cool halo by default; set equal to onColor for a flat, glow-free bitmap. |
| label | string | "Loading" | Accessible label announced while loading. |
| showLabel | boolean | false | Render a blocky, marching label row beneath the grid. |
| labelText | string | "LOADING" | Text of the marching label (uppercased, letter-spaced). |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.