# Flurry — Animated Background for React

> Quantized pixel snowfall: chunky flakes locked to a crisp unsmoothed pixel grid fall on three parallax depth layers with distinct speeds, sizes and ice-blue tints. Per-flake sine wander, seeded wind gusts that shear layers by depth, and flakes that settle briefly into 1-block drifts on an invisible floor. Canvas 2D, seeded, offscreen-paused, reduced-motion-safe.

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

## Install

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

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

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

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 (`FlurryProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colors?` | `string[]` | `["#eff6ff", "#bfdbfe", "#93c5fd"]` | Layer tints, nearest → farthest. Index `0` colors the big foreground flakes, `1` the mid layer, `2` the distant dust. Defaults to an icy white-to-blue ramp on a cold near-black bed — pass warm grays for ash, greens for pollen, anything for confetti-weather. |
| `density?` | `number` | `1` | Flake-count multiplier across all layers, 0.2–3. |
| `speed?` | `number` | `1` | Fall-speed multiplier (gust strength is set separately by `wind`). 1 = a lazy flurry. |
| `wind?` | `number` | `1` | Gust strength multiplier, 0–3. Gusts arrive on a seeded schedule and shear every layer proportionally to its depth; `0` disables them. |
| `drift?` | `number` | `6` | Constant fall angle in degrees from vertical (positive leans right). Clamped −45…45. |
| `pixelSize?` | `number` | `3` | Chunk size of one "pixel" in CSS px — flakes snap to this grid. Clamped 2–8. |
| `settle?` | `boolean` | `true` | Let flakes settle on an invisible floor line: each lands, flattens into a 1-block drift for a moment, then fades and recycles. |
| `intensity?` | `number` | `1` | Overall flake brightness, 0–2. |
| `seed?` | `number` | `1` | Deterministic seed — same seed always renders the same snowfall. |
| `dpr?` | `number` | `2` | devicePixelRatio ceiling. Clamped 1–2. |
| `paused?` | `boolean` | `false` | Freeze the flurry at a static mid-fall scatter. |

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 { Flurry } from "@/components/crucible/flurry";
import { DemoScene } from "@/components/registry/demo-scene";

export default function FlurryDemo() {
  return (
    <DemoScene
      background={<Flurry />}
      headline="Snow, one pixel at a time"
      subhead="Three parallax layers of quantized snowfall — wandering flakes, passing gusts, drifts settling on the 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 (`flurry.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 FlurryProps extends React.ComponentPropsWithoutRef<"div"> {
  /**
   * Layer tints, nearest → farthest. Index `0` colors the big foreground
   * flakes, `1` the mid layer, `2` the distant dust. Defaults to an icy
   * white-to-blue ramp on a cold near-black bed — pass warm grays for ash,
   * greens for pollen, anything for confetti-weather.
   * @default ["#eff6ff", "#bfdbfe", "#93c5fd"]
   */
  colors?: string[];
  /** Flake-count multiplier across all layers, 0.2–3. @default 1 */
  density?: number;
  /** Fall-speed multiplier (gust strength is set separately by `wind`). 1 = a lazy flurry. @default 1 */
  speed?: number;
  /**
   * Gust strength multiplier, 0–3. Gusts arrive on a seeded schedule and
   * shear every layer proportionally to its depth; `0` disables them.
   * @default 1
   */
  wind?: number;
  /**
   * Constant fall angle in degrees from vertical (positive leans right).
   * Clamped −45…45. @default 6
   */
  drift?: number;
  /** Chunk size of one "pixel" in CSS px — flakes snap to this grid. Clamped 2–8. @default 3 */
  pixelSize?: number;
  /**
   * Let flakes settle on an invisible floor line: each lands, flattens into a
   * 1-block drift for a moment, then fades and recycles. @default true
   */
  settle?: boolean;
  /** Overall flake brightness, 0–2. @default 1 */
  intensity?: number;
  /** Deterministic seed — same seed always renders the same snowfall. @default 1 */
  seed?: number;
  /** devicePixelRatio ceiling. Clamped 1–2. @default 2 */
  dpr?: number;
  /** Freeze the flurry at a static mid-fall scatter. @default false */
  paused?: boolean;
}

const DEFAULT_COLORS: string[] = ["#eff6ff", "#bfdbfe", "#93c5fd"];

/** How strongly gusts shear each layer — near snow catches the wind, far snow barely stirs. */
const GUST_SHEAR: [number, number, number] = [1, 0.55, 0.28];

/** Deterministic mulberry32 — seeded so the field is identical across mounts (SSR-safe). */
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;
  };
}

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 [239, 246, 255];
  return [(int >> 16) & 255, (int >> 8) & 255, int & 255];
}

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

interface Flake {
  x: number; // CSS px (unquantized sim position)
  y: number;
  layer: 0 | 1 | 2; // 0 near, 1 mid, 2 far
  vy: number; // fall speed, CSS px/s (before global speed)
  wanderAmp: number; // horizontal sine sway, CSS px
  wanderFreq: number;
  wanderPhase: number;
  baseAlpha: number;
  cross: boolean; // near flakes only: plus-shaped sparkle variant
  settled: boolean;
  settleT: number; // seconds spent on the floor
  settleDur: number;
}

interface Internals {
  ctx: CanvasRenderingContext2D;
  width: number; // CSS px
  height: number;
  P: number; // pixel-block size, CSS px
  palette: [number, number, number][];
  bg: string;
  flakes: Flake[];
  rng: () => number; // runtime recycling stream
  // Live knobs (mutated in place — no canvas rebuild on change).
  speed: number;
  wind: number;
  driftTan: number;
  intensity: number;
  settle: boolean;
  // Seeded gust schedule.
  gustStart: number;
  gustDur: number;
  gustStrength: number; // signed CSS px/s at envelope peak
}

function makeFlake(rng: () => number, layer: 0 | 1 | 2, w: number, h: number, initial: boolean): Flake {
  const near = layer === 0;
  const mid = layer === 1;
  return {
    x: rng() * w,
    // First build scatters mid-fall across the full height so frame zero (and
    // the reduced-motion still) is already a populated snowfall.
    y: initial ? rng() * h : -4 - rng() * h * 0.15,
    layer,
    vy: (near ? 46 : mid ? 26 : 13) * (0.75 + rng() * 0.5),
    wanderAmp: near ? 8 + rng() * 8 : mid ? 5 + rng() * 5 : 2 + rng() * 3,
    wanderFreq: 0.3 + rng() * 0.6,
    wanderPhase: rng() * Math.PI * 2,
    baseAlpha: near ? 0.95 : mid ? 0.6 : 0.35,
    cross: near && rng() < 0.3,
    settled: false,
    settleT: 0,
    settleDur: 1.2 + rng() * 1.4,
  };
}

function buildField(w: number, h: number, density: number, settle: boolean, seed: number): Flake[] {
  const rng = mulberry32((seed ^ 0x5eed5307) >>> 0);
  const area = w * h;
  const farCount = Math.min(320, Math.max(16, Math.round((area / 3800) * density)));
  const midCount = Math.round(farCount * 0.55);
  const nearCount = Math.max(3, Math.round(farCount * 0.28));
  const flakes: Flake[] = [];
  const push = (layer: 0 | 1 | 2, n: number) => {
    for (let i = 0; i < n; i++) {
      const f = makeFlake(rng, layer, w, h, true);
      // A seeded handful start already settled so the floor detail shows in
      // the static frame and from frame one.
      if (settle && rng() < 0.08) {
        f.settled = true;
        f.settleT = rng() * f.settleDur * 0.7;
      }
      flakes.push(f);
    }
  };
  push(2, farCount);
  push(1, midCount);
  push(0, nearCount);
  return flakes;
}

/** Send a flake back above the top edge with fresh seeded character. */
function respawn(f: Flake, it: Internals) {
  const fresh = makeFlake(it.rng, f.layer, it.width, it.height, false);
  Object.assign(f, fresh);
}

/**
 * Smooth gust envelope from the seeded schedule: 0 between gusts, sin² hump
 * during one. Advancing past a gust schedules the next from the seeded stream,
 * so the whole storm is deterministic.
 */
function gustAt(it: Internals, t: number): number {
  const end = it.gustStart + it.gustDur;
  if (t >= end) {
    it.gustStart = t + 3 + it.rng() * 6;
    it.gustDur = 1.2 + it.rng() * 1.8;
    it.gustStrength = (it.rng() < 0.5 ? -1 : 1) * (34 + it.rng() * 52);
    return 0;
  }
  if (t < it.gustStart) return 0;
  const p = (t - it.gustStart) / it.gustDur;
  const s = Math.sin(Math.PI * p);
  return it.gustStrength * s * s;
}

function fillBlock(it: Internals, qx: number, qy: number, w: number, h: number, rgb: [number, number, number], alpha: number) {
  if (alpha <= 0.01) return;
  it.ctx.fillStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${Math.min(alpha, 1)})`;
  it.ctx.fillRect(qx, qy, w, h);
}

/** Draw one flake, quantized to the pixel grid — integer blocks, zero subpixel churn. */
function drawFlake(it: Internals, f: Flake, t: number) {
  const { P } = it;
  const rgb = it.palette[f.layer];
  const floorY = Math.max(it.height - 2 * P, P);

  if (f.settled) {
    // Landed: a flat 1-block drift, one block wider than the flake, fading out
    // over the back half of its rest.
    const fade = 1 - Math.max(0, (f.settleT / f.settleDur - 0.4) / 0.6);
    const wBlocks = f.layer === 0 ? 3 : 2;
    const qx = Math.round(f.x / P) * P;
    const qy = Math.round(floorY / P) * P;
    fillBlock(it, qx - ((wBlocks / 2) | 0) * P, qy, wBlocks * P, P, rgb, f.baseAlpha * fade * it.intensity);
    return;
  }

  const xd = f.x + Math.sin(f.wanderPhase + t * f.wanderFreq) * f.wanderAmp;
  const qx = Math.round(xd / P) * P;
  const qy = Math.round(f.y / P) * P;
  const a = f.baseAlpha * it.intensity;

  if (f.layer === 0) {
    if (f.cross) {
      // Plus-shaped sparkle flake — pixel-craft variety in the near field.
      fillBlock(it, qx, qy, P, P, rgb, a);
      fillBlock(it, qx - P, qy, P, P, rgb, a * 0.75);
      fillBlock(it, qx + P, qy, P, P, rgb, a * 0.75);
      fillBlock(it, qx, qy - P, P, P, rgb, a * 0.75);
      fillBlock(it, qx, qy + P, P, P, rgb, a * 0.75);
    } else {
      fillBlock(it, qx, qy, 2 * P, 2 * P, rgb, a);
    }
  } else {
    fillBlock(it, qx, qy, P, P, rgb, a);
  }
}

/** One frame. `animate` false renders the designed static scatter (no advance). */
function draw(it: Internals, dt: number, t: number, animate: boolean) {
  const { ctx, width, height, P } = it;
  ctx.globalCompositeOperation = "source-over";
  ctx.fillStyle = it.bg;
  ctx.fillRect(0, 0, width, height);

  const gust = animate ? gustAt(it, t) * it.wind : 0;
  const floorY = Math.max(height - 2 * P, P);
  const margin = 4 * P;
  const span = width + 2 * margin;

  for (let i = 0; i < it.flakes.length; i++) {
    const f = it.flakes[i];
    if (animate) {
      if (f.settled) {
        f.settleT += dt;
        if (f.settleT >= f.settleDur) respawn(f, it);
      } else {
        const fall = f.vy * it.speed;
        f.y += fall * dt;
        f.x += (fall * it.driftTan + gust * GUST_SHEAR[f.layer]) * dt;
        // Wrap horizontally so drift and gusts never thin the field.
        if (f.x < -margin) f.x += span;
        else if (f.x > width + margin) f.x -= span;
        if (f.y >= floorY) {
          if (it.settle && it.rng() < 0.55) {
            f.settled = true;
            f.settleT = 0;
            f.y = floorY;
          } else {
            respawn(f, it);
          }
        }
      }
    }
    drawFlake(it, f, animate ? t : 0);
  }
}

/**
 * Flurry — quantized pixel snowfall. Chunky flakes locked to a crisp,
 * unsmoothed pixel grid drift down on three parallax depth layers (big bright
 * near chunks, mid blocks, distant dust — each with its own fall speed and
 * ice-blue tint). Every flake wanders on its own horizontal sine; seeded wind
 * gusts sweep through and shear all layers proportionally to depth; flakes
 * settle briefly on an invisible floor line — flattening into a 1-block drift
 * before fading. Canvas 2D: integer block positions (no subpixel churn),
 * seeded PRNG (SSR-safe), paused offscreen and on hidden tabs, reduced-motion
 * renders a static seeded mid-fall scatter. Drop it inside any `relative`
 * container.
 */
export function Flurry({
  colors = DEFAULT_COLORS,
  density = 1,
  speed = 1,
  wind = 1,
  drift = 6,
  pixelSize = 3,
  settle = true,
  intensity = 1,
  seed = 1,
  dpr = 2,
  paused = false,
  className,
  ...props
}: FlurryProps) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const internalsRef = React.useRef<Internals | null>(null);
  const reducedMotion = useReducedMotion();
  const frozen = paused || reducedMotion;
  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 speedF = Math.max(speed, 0.01);
  const windF = Math.min(Math.max(wind, 0), 3);
  const driftTan = Math.tan((Math.min(Math.max(drift, -45), 45) * Math.PI) / 180);
  const intens = Math.min(Math.max(intensity, 0), 2);
  const colorsKey = colors.join(",");

  const tick = React.useCallback((elapsed: number) => {
    const it = internalsRef.current;
    if (!it) return;
    // useVisibilityPause restarts `elapsed` at 0 whenever its `paused` option
    // flips; re-seat so the first frame after a resume steps by 0, not backwards.
    if (elapsed < lastElapsedRef.current) lastElapsedRef.current = elapsed;
    let dt = elapsed - lastElapsedRef.current;
    lastElapsedRef.current = elapsed;
    if (dt > 0.05) dt = 0.05; // clamp tab-restore jumps
    if (dt < 0) dt = 0;
    // The rAF loop only runs while unfrozen (useVisibilityPause gates it), so
    // ticks always animate; the frozen still is drawn by the effects below.
    draw(it, dt, elapsed, true);
  }, []);

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

  // Canvas + field setup; rebuilds only on structural props (size via observer).
  React.useEffect(() => {
    const canvas = canvasRef.current;
    const container = containerRef.current;
    if (!canvas || !container) return;

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

    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) as [number, number, number][];
    while (palette.length < 3) palette.push(palette[palette.length - 1]);

    const rng = mulberry32((seed ^ 0x9e3779b9) >>> 0);
    const it: Internals = {
      ctx,
      width: 0,
      height: 0,
      P,
      palette,
      bg: baseFill(palette[2]),
      flakes: [],
      rng,
      speed: speedF,
      wind: windF,
      driftTan,
      intensity: intens,
      settle,
      gustStart: 2 + rng() * 4,
      gustDur: 1.2 + rng() * 1.8,
      gustStrength: (rng() < 0.5 ? -1 : 1) * (34 + rng() * 52),
    };
    internalsRef.current = it;

    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.flakes = buildField(w, h, dens, it.settle, seed);
      // Never flash empty — paint the seeded scatter immediately.
      draw(it, 0, 0, false);
    };

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

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

  // Keep behavior knobs live in place (no rebuild), and re-render the frozen
  // frame so prop tweaks show while paused / reduced-motion.
  React.useEffect(() => {
    const it = internalsRef.current;
    if (!it) return;
    it.speed = speedF;
    it.wind = windF;
    it.driftTan = driftTan;
    it.intensity = intens;
    it.settle = settle;
    if (frozen) draw(it, 0, 0, false);
  }, [speedF, windF, driftTan, intens, settle, frozen]);

  return (
    <div
      ref={containerRef}
      aria-hidden
      data-crucible="flurry"
      className={cn("absolute inset-0 -z-10 overflow-hidden bg-neutral-950", className)}
      {...props}
    >
      <canvas ref={canvasRef} className="block h-full w-full" />
    </div>
  );
}
```
