Arcade
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.
canvasfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/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, 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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/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 all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, pauses offscreen and on hidden tabs, and passes className through.