# Bitmap — Animated Loader for React

> 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.

- Page: https://crucibleui.com/components/loaders/bitmap
- Category: [loaders](https://crucibleui.com/components/loaders)
- Tier: free (full source)
- Engine: css

## Install

```bash
npx shadcn@latest add @crucible/bitmap
```

Registry JSON: https://crucibleui.com/r/bitmap.json

Import from `@/components/crucible/bitmap`.

## Props (`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: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Bitmap } from "@/components/crucible/bitmap";

export default function BitmapDemo() {
  // Drive the determinate bar so the cell-by-cell fill reads at a glance.
  const [value, setValue] = React.useState(0);
  React.useEffect(() => {
    const id = window.setInterval(() => {
      setValue((v) => (v >= 100 ? 0 : v + 4));
    }, 320);
    return () => window.clearInterval(id);
  }, []);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full flex-col items-center justify-center gap-10 overflow-hidden rounded-xl bg-neutral-950 p-8">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />

      <p className="relative z-10 max-w-md text-center text-sm text-neutral-400">
        A chunky 8-bit pixel loader — the bar fills cell-by-cell toward a value,
        or a lit band marches while you wait.
      </p>

      <div className="relative z-10 flex flex-wrap items-end justify-center gap-x-16 gap-y-10">
        <div className="flex flex-col items-center gap-4">
          <Bitmap variant="bar" value={value} label="Downloading" />
          <span className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600">
            Determinate
          </span>
        </div>

        <div className="flex flex-col items-center gap-4">
          <Bitmap variant="bar" label="Loading" />
          <span className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600">
            Indeterminate
          </span>
        </div>

        <div className="flex flex-col items-center gap-4">
          <Bitmap variant="spinner" size={11} label="Loading" />
          <span className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600">
            Spinner
          </span>
        </div>
      </div>

      <div className="relative z-10">
        <Bitmap variant="bar" cells={28} rows={2} size={9} showLabel label="Loading" />
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`bitmap.tsx`)

```tsx
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, phase-offset by its position
            // so a lit band sweeps left→right (column x peaks x/cols of a cycle
            // after column 0). Resting frame = first 60% lit.
            return (
              <Cell
                key={`${x}-${y}`}
                rest={x < restBand ? 1 : 0.1}
                animated
                delay={`${(-(1 - 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
            // Phase-offset so the head travels clockwise (later angle = later peak).
            const delay = `${(-(1 - 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: `${(-(1 - i / 3) * (0.75 * (1.6 / Math.max(speed, 0.05)))).toFixed(3)}s` }}
              />
            ))}
          </span>
        </div>
      )}

      <span className="sr-only">{label}</span>
    </div>
  );
}
```
