# Arcade — Animated Background for React

> 8-bit attract-mode background: a chunky pixel starfield drifting on three parallax depth layers, seeded space-shooter sprites gliding across on a two-frame flap, and a faint pixel grid receding to a horizon. Crisp unsmoothed pixels. Canvas 2D, seeded, offscreen-paused, reduced-motion-safe.

- Page: https://crucibleui.com/components/backgrounds/arcade
- Category: [backgrounds](https://crucibleui.com/backgrounds)
- Tier: free (full source)
- Engine: canvas

## Install

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

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

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

Backgrounds render `absolute inset-0 -z-10`. Their parent must be a stacking context — wrap in `relative isolate` and put your content in a `relative z-10` child, or the background disappears behind an opaque ancestor.

## Props (`ArcadeProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors?` | `string[]` | `["#eaf6ff", "#7fdfff", "#4aa6ff"]` | Star / grid / sprite palette, brightest → deepest. Index `0` is the near (foreground) star + sprite tint, `1` the mid layer + floor grid, `2` the far layer. Extra entries recolor the drifting sprites. Defaults to a cool cyan-to-indigo starfield on near-black — pass warm tones for a synthwave cabinet or all-white for classic mono. |
| `speed?` | `number` | `1` | Drift / flap / floor-scroll speed multiplier. 1 = default. |
| `intensity?` | `number` | `1` | Overall star brightness and grid/sprite opacity, 0–2. |
| `density?` | `number` | `1` | Star-count multiplier across all layers, 0.2–3. |
| `pixelSize?` | `number` | `3` | Chunk size of one "pixel" in CSS px — the whole scene snaps to it. Clamped 2–8. |
| `dpr?` | `number` | `2` | devicePixelRatio ceiling. Clamped 1–2. |
| `paused?` | `boolean` | `false` | Freeze the scene at a static, populated attract-mode frame. |

Also accepts: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

`DemoScene` is a site-only preview harness, not part of the component — replace it with your own layout.

```tsx
import { Arcade } from "@/components/crucible/arcade";
import { DemoScene } from "@/components/registry/demo-scene";

export default function ArcadeDemo() {
  return (
    <DemoScene
      background={<Arcade />}
      headline="Insert coin to begin"
      subhead="A parallax pixel starfield, drifting sprites and a receding grid floor."
    />
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.

## Source (`arcade.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

export interface ArcadeProps extends React.ComponentPropsWithoutRef<"div"> {
  /**
   * Star / grid / sprite palette, brightest → deepest. Index `0` is the near
   * (foreground) star + sprite tint, `1` the mid layer + floor grid, `2` the
   * far layer. Extra entries recolor the drifting sprites. Defaults to a cool
   * cyan-to-indigo starfield on near-black — pass warm tones for a synthwave
   * cabinet or all-white for classic mono.
   * @default ["#eaf6ff", "#7fdfff", "#4aa6ff"]
   */
  colors?: string[];
  /** Drift / flap / floor-scroll speed multiplier. 1 = default. @default 1 */
  speed?: number;
  /** Overall star brightness and grid/sprite opacity, 0–2. @default 1 */
  intensity?: number;
  /** Star-count multiplier across all layers, 0.2–3. @default 1 */
  density?: number;
  /** Chunk size of one "pixel" in CSS px — the whole scene snaps to it. Clamped 2–8. @default 3 */
  pixelSize?: number;
  /** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
  dpr?: number;
  /** Freeze the scene at a static, populated attract-mode frame. @default false */
  paused?: boolean;
}

const DEFAULT_COLORS: string[] = ["#eaf6ff", "#7fdfff", "#4aa6ff"];

/** Deterministic mulberry32 — seeded so the field is identical SSR intent ↔ CSR. */
function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const SEED = 0x51ed270b;

function hexToRgb(hex: string): [number, number, number] {
  let h = hex.replace("#", "").trim();
  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
  const int = parseInt(h, 16);
  if (Number.isNaN(int) || h.length !== 6) return [234, 246, 255];
  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}

/**
 * 8-bit sprite bitmaps — two flap frames each. `#` = a filled pixel-block.
 * Clean-room classic space-shooter silhouettes (crab + octopus invaders).
 */
const SPRITES: { w: number; h: number; frames: string[][] }[] = [
  {
    w: 11,
    h: 8,
    frames: [
      [
        "..#.....#..",
        "...#...#...",
        "..#######..",
        ".##.###.##.",
        "###########",
        "#.#######.#",
        "#.#.....#.#",
        "...##.##...",
      ],
      [
        "..#.....#..",
        "#..#...#..#",
        "#.#######.#",
        "###.###.###",
        "###########",
        ".#########.",
        "..#.....#..",
        ".#.......#.",
      ],
    ],
  },
  {
    w: 12,
    h: 8,
    frames: [
      [
        "....####....",
        ".##########.",
        "############",
        "###..##..###",
        "############",
        "...##..##...",
        "..##.##.##..",
        "##........##",
      ],
      [
        "....####....",
        ".##########.",
        "############",
        "###..##..###",
        "############",
        "..###..###..",
        ".##..##..##.",
        "..##....##..",
      ],
    ],
  },
];

const NLINES = 16; // floor rows
const NVERT = 18; // floor verticals

interface Star {
  x: number; // CSS px
  y: number;
  layer: 0 | 1 | 2; // 0 near, 1 mid, 2 far
  vy: number; // CSS px/s drift
  baseAlpha: number;
  colorIdx: number;
  twPhase: number;
  twFreq: number;
}

interface Sprite {
  x: number; // CSS px (top-left)
  y: number;
  dir: 1 | -1;
  vx: number; // CSS px/s
  type: number; // SPRITES index
  colorIdx: number;
  flapPhase: number;
  flapRate: number; // toggles/sec
}

interface Internals {
  ctx: CanvasRenderingContext2D;
  width: number;
  height: number;
  P: number; // pixel-block size, CSS px
  palette: [number, number, number][];
  stars: Star[];
  sprites: Sprite[];
  gridOffset: number;
  intensity: number;
  speed: number;
  rng: () => number;
  bg: string;
}

function buildStars(w: number, h: number, P: number, density: number): Star[] {
  const rng = mulberry32(SEED ^ 0x1b56c4e9);
  const area = w * h;
  const farCount = Math.min(360, Math.max(12, Math.round((area / 2600) * density)));
  const midCount = Math.round(farCount * 0.5);
  const nearCount = Math.round(farCount * 0.16);
  const stars: Star[] = [];

  const push = (layer: 0 | 1 | 2, n: number) => {
    for (let i = 0; i < n; i++) {
      const near = layer === 0;
      const mid = layer === 1;
      stars.push({
        x: rng() * w,
        y: rng() * h,
        layer,
        vy: (near ? 34 : mid ? 18 : 8) * (0.75 + rng() * 0.5),
        baseAlpha: near ? 0.85 : mid ? 0.6 : 0.32,
        colorIdx: layer, // near→0, mid→1, far→2
        twPhase: rng() * Math.PI * 2,
        twFreq: near ? 2.2 + rng() * 2.4 : mid ? 1.2 + rng() * 1.6 : 0,
      });
    }
  };
  push(2, farCount);
  push(1, midCount);
  push(0, nearCount);
  return stars;
}

function buildSprites(w: number, h: number, paletteLen: number): Sprite[] {
  const rng = mulberry32(SEED ^ 0x27d4eb2f);
  const out: Sprite[] = [];
  const band = Math.max(h * 0.12, 40);
  for (let i = 0; i < 3; i++) {
    const dir: 1 | -1 = rng() < 0.5 ? 1 : -1;
    out.push({
      // Seeded across the frame so the static / first frame already shows craft.
      x: rng() * w,
      y: band + rng() * (h * 0.42),
      dir,
      vx: (22 + rng() * 20) * (0.85 + rng() * 0.4),
      type: (rng() * SPRITES.length) | 0,
      colorIdx: (rng() * paletteLen) | 0,
      flapPhase: rng() * 10,
      flapRate: 1.4 + rng() * 0.9,
    });
  }
  return out;
}

function recycleSprite(s: Sprite, it: Internals) {
  const rng = it.rng;
  const spr = SPRITES[s.type];
  const wpx = spr.w * it.P * 2;
  s.dir = rng() < 0.5 ? 1 : -1;
  s.type = (rng() * SPRITES.length) | 0;
  s.colorIdx = (rng() * it.palette.length) | 0;
  s.vx = (22 + rng() * 20) * (0.85 + rng() * 0.4);
  s.y = Math.max(it.height * 0.12, 40) + rng() * (it.height * 0.42);
  s.flapRate = 1.4 + rng() * 0.9;
  // Enter from the side it travels toward, with a seeded gap so appearances
  // are occasional rather than a constant conveyor.
  const gap = wpx + rng() * (it.width * 0.9 + wpx);
  s.x = s.dir === 1 ? -gap : it.width + gap;
}

function drawBlock(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  size: number,
  rgb: [number, number, number],
  alpha: number
) {
  if (alpha <= 0.01) return;
  ctx.fillStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha < 1 ? alpha : 1})`;
  ctx.fillRect(Math.round(x), Math.round(y), size, size);
}

/** Perspective pixel-grid floor: rows compress toward a horizon, verticals fan from a vanishing point. */
function drawGrid(it: Internals, offset: number) {
  const { ctx, width, height, P } = it;
  const hY = Math.round(height * 0.66);
  const H = height - hY;
  if (H <= 4) return;
  const cx = width / 2;
  const rgb = it.palette[Math.min(1, it.palette.length - 1)];
  const thick = Math.max(1, Math.round(P * 0.6));
  const dot = P;
  const gAmt = Math.min(Math.max(it.intensity, 0), 2);

  for (let k = 0; k < NLINES; k++) {
    let p = ((k + offset) % NLINES) / NLINES;
    if (p < 0) p += 1;
    const persp = p * p; // near horizon → 0, bottom → 1
    const sy = hY + H * persp;
    const fade = (0.12 + 0.88 * p) * gAmt;
    // Floor row — a thin chunky full-width scan line.
    ctx.fillStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${0.06 * fade})`;
    ctx.fillRect(0, Math.round(sy), width, thick);
    // Vertical intersections — dots converging on the vanishing point.
    for (let v = 0; v <= NVERT; v++) {
      const bx = (v / NVERT) * width * 2 - width * 0.5;
      const x = cx + (bx - cx) * persp;
      if (x < -dot || x > width + dot) continue;
      drawBlock(ctx, x, sy, dot, rgb, 0.16 * fade);
    }
  }
}

function drawSprite(it: Internals, s: Sprite, frame: number) {
  const spr = SPRITES[s.type];
  const rows = spr.frames[frame % spr.frames.length];
  const block = it.P * 2;
  const rgb = it.palette[s.colorIdx % it.palette.length];
  const alpha = Math.min(0.85 * it.intensity, 1);
  const x0 = Math.round(s.x / it.P) * it.P;
  const y0 = Math.round(s.y / it.P) * it.P;
  it.ctx.fillStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha})`;
  for (let r = 0; r < rows.length; r++) {
    const row = rows[r];
    for (let c = 0; c < row.length; c++) {
      if (row[c] === "#") {
        it.ctx.fillRect(x0 + c * block, y0 + r * block, block, block);
      }
    }
  }
}

/** One frame. `t` is elapsed seconds (phases); `useStatic` freezes drift, flap and scroll. */
function draw(it: Internals, dt: number, t: number, useStatic: boolean) {
  const { ctx, width, height, P } = it;

  ctx.globalCompositeOperation = "source-over";
  ctx.fillStyle = it.bg;
  ctx.fillRect(0, 0, width, height);

  // ------------------------------------------------------------------ floor --
  if (!useStatic) it.gridOffset += dt * it.speed * 1.1;
  drawGrid(it, useStatic ? 0 : it.gridOffset);

  // ------------------------------------------------------------------ stars --
  for (let i = 0; i < it.stars.length; i++) {
    const s = it.stars[i];
    if (!useStatic) {
      s.y += s.vy * it.speed * dt;
      if (s.y > height + P) s.y -= height + P * 2;
    }
    const rgb = it.palette[s.colorIdx % it.palette.length];
    const tw =
      s.twFreq && !useStatic ? 0.55 + 0.45 * Math.sin(s.twPhase + t * s.twFreq) : 0.9;
    const alpha = Math.min(s.baseAlpha * tw * it.intensity, 1);
    const x = Math.round(s.x / P) * P;
    const y = Math.round(s.y / P) * P;
    if (s.layer === 0) {
      // Near stars are a bright pixel-cross (classic twinkle sprite).
      drawBlock(ctx, x, y, P, rgb, alpha);
      drawBlock(ctx, x - P, y, P, rgb, alpha * 0.7);
      drawBlock(ctx, x + P, y, P, rgb, alpha * 0.7);
      drawBlock(ctx, x, y - P, P, rgb, alpha * 0.7);
      drawBlock(ctx, x, y + P, P, rgb, alpha * 0.7);
    } else {
      drawBlock(ctx, x, y, P, rgb, alpha);
    }
  }

  // ---------------------------------------------------------------- sprites --
  for (let i = 0; i < it.sprites.length; i++) {
    const s = it.sprites[i];
    const spr = SPRITES[s.type];
    if (!useStatic) {
      s.x += s.vx * s.dir * it.speed * dt;
      const wpx = spr.w * it.P * 2;
      if (s.dir === 1 && s.x > width + wpx) recycleSprite(s, it);
      else if (s.dir === -1 && s.x < -wpx) recycleSprite(s, it);
    }
    const frame = useStatic ? 0 : Math.floor(s.flapPhase + t * s.flapRate) % 2;
    drawSprite(it, s, frame);
  }
}

/** Cool near-black bed derived from the far-star hue — never flat #000. */
function baseFill(far: [number, number, number]): string {
  return `rgb(${(4 + far[0] * 0.01) | 0}, ${(6 + far[1] * 0.012) | 0}, ${(10 + far[2] * 0.018) | 0})`;
}

/**
 * Arcade — an 8-bit attract-mode background. A chunky pixel starfield drifts on
 * three parallax depth layers (bright near cross-stars, mid and far dots),
 * seeded space-shooter sprites glide across on a two-frame flap, and a faint
 * pixel grid recedes to a horizon like a cabinet's demo screen. Crisp,
 * unsmoothed pixels snapped to a block grid. Canvas 2D: seeded and SSR-safe,
 * paused offscreen and on hidden tabs, reduced-motion-safe (a static, fully
 * populated frame — no drift). Drop it inside any `relative` container.
 */
export function Arcade({
  colors = DEFAULT_COLORS,
  speed = 1,
  intensity = 1,
  density = 1,
  pixelSize = 3,
  dpr = 2,
  paused = false,
  className,
  ...props
}: ArcadeProps) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const internalsRef = React.useRef<Internals | null>(null);
  const reducedMotion = useReducedMotion();
  const staticFrame = paused || reducedMotion;
  const staticFrameRef = React.useRef(staticFrame);
  staticFrameRef.current = staticFrame;
  const lastElapsedRef = React.useRef(0);

  const P = Math.round(Math.min(Math.max(pixelSize, 2), 8));
  const dens = Math.min(Math.max(density, 0.2), 3);
  const colorsKey = colors.join(",");

  const tick = React.useCallback((elapsed: number) => {
    const it = internalsRef.current;
    if (!it) return;
    let dt = elapsed - lastElapsedRef.current;
    lastElapsedRef.current = elapsed;
    if (dt > 0.1) dt = 0.1; // clamp tab-restore jumps
    if (dt < 0) dt = 0;
    draw(it, dt, elapsed, staticFrameRef.current);
  }, []);

  const containerRef = useVisibilityPause<HTMLDivElement>(tick, { paused: staticFrame });

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const container = containerRef.current;
    if (!canvas || !container) return;

    const ctx = canvas.getContext("2d", { alpha: false });
    if (!ctx) return;
    ctx.imageSmoothingEnabled = false;

    const effectiveDpr = Math.min(
      typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
      Math.min(Math.max(dpr, 1), 2)
    );

    const source = colors.length ? colors : DEFAULT_COLORS;
    const palette = source.map(hexToRgb);
    while (palette.length < 3) palette.push(palette[palette.length - 1]);

    const it: Internals = {
      ctx,
      width: 0,
      height: 0,
      P,
      palette,
      stars: [],
      sprites: [],
      gridOffset: 0,
      intensity,
      speed,
      rng: mulberry32(SEED ^ 0x85ebca6b),
      bg: baseFill(palette[Math.min(2, palette.length - 1)]),
    };
    internalsRef.current = it;

    const onContextLost = (e: Event) => e.preventDefault();
    canvas.addEventListener("contextlost", onContextLost);

    const resize = () => {
      const w = Math.max(container.clientWidth, 1);
      const h = Math.max(container.clientHeight, 1);
      canvas.width = Math.round(w * effectiveDpr);
      canvas.height = Math.round(h * effectiveDpr);
      canvas.style.width = "100%";
      canvas.style.height = "100%";
      ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
      ctx.imageSmoothingEnabled = false;

      it.width = w;
      it.height = h;
      it.stars = buildStars(w, h, P, dens);
      it.sprites = buildSprites(w, h, palette.length);
      it.gridOffset = 0;

      // Never flash empty — paint one frame immediately.
      draw(it, 0, 0, staticFrameRef.current);
    };

    const ro = new ResizeObserver(resize);
    ro.observe(container);
    resize();

    return () => {
      ro.disconnect();
      canvas.removeEventListener("contextlost", onContextLost);
      internalsRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dpr, P, dens, colorsKey]);

  // Live-sync speed / intensity without rebuilding (and resetting) the field.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it) return;
    it.speed = speed;
    it.intensity = intensity;
    if (staticFrameRef.current) draw(it, 0, 0, true);
  }, [speed, intensity]);

  // Re-render the frozen frame in place when paused / reduced-motion toggles.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it || !staticFrame) return;
    draw(it, 0, 0, true);
  }, [staticFrame]);

  return (
    <div
      ref={containerRef}
      aria-hidden
      data-crucible="arcade"
      className={cn("absolute inset-0 -z-10 overflow-hidden bg-neutral-950", className)}
      {...props}
    >
      <canvas ref={canvasRef} className="block h-full w-full" />
      {/* CRT scanlines + faint vignette — sink the cabinet into the frame. */}
      <div
        className="pointer-events-none absolute inset-0"
        style={{
          backgroundImage:
            "repeating-linear-gradient(0deg, rgba(0,0,0,0.32) 0px, rgba(0,0,0,0.32) 1px, transparent 1px, transparent 3px)",
          opacity: 0.5,
        }}
      />
      <div
        className="pointer-events-none absolute inset-0"
        style={{
          background:
            "radial-gradient(120% 120% at 50% 40%, transparent 58%, rgba(0,0,0,0.6) 100%)",
        }}
      />
    </div>
  );
}
```
