← All particles

Sand Flow

Grains blowing along a surface, piling into drifts that travel downwind.

ambientcalmminimal200 particles · light · canvas-2d · automatic · looping
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

320 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Sand Flow
 *
 * Grains skittering along a surface and piling into drifts that slowly
 * travel downwind.
 *
 * The technique: a grain that lands on the sheltered lee side sticks,
 * and a grain that lands climbing the windward face usually bounces on.
 * That asymmetry is the only reason drifts exist. Deposit grains evenly
 * and the surface can only flatten, however many of them there are —
 * flat is the single stable state. Let the lee trap them and any small
 * bump starts collecting more than it loses, so drifts grow, and because
 * material keeps leaving the windward side to land behind the crest, the
 * whole drift travels downwind.
 *
 * The second rule gives them their shape: the height field is relaxed to
 * a maximum slope, the angle of repose, so any column standing more than
 * about thirty-four degrees above its neighbour topples the excess
 * sideways. Real drifts have flanks at a constant angle; a heap built
 * without that rule reads as dough no matter how fine the grains are.
 *
 * Erosion is uniform across the surface, and that detail is load-bearing
 * in the other direction: lifting grains preferentially from the tallest
 * column shaves the crests as fast as the lee builds them, and the field
 * flattens back out no matter how good the deposition rule is.
 *
 * Mass is conserved exactly — a landing grain's volume joins a column
 * and the same volume is lifted somewhere else in the same instant — so
 * the field never fills up and the grain count never changes.
 *
 * Self-contained: one canvas, no dependencies at all.
 * Works with zero props; tune via `count`, `colors`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SandFlowProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Grains in the air at once. Constant, by construction. */
  count?: number;
  /** Airborne grain color. */
  grainColor?: string;
  /** The settled drift's body color. */
  driftColor?: string;
};

type VariantConfig = {
  /** Wind speed in px per second. */
  wind: number;
  /** Upward kick when a grain is lifted, in px per second. */
  hop: number;
  /** Grain size in px. */
  grain: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A slow surface creep, barely moving.
  subtle: { wind: 22, hop: 62, grain: 1 },
  // Reads as flowing sand without becoming a sandstorm. All-purpose.
  default: { wind: 40, hop: 92, grain: 1.2 },
  // A stronger wind: longer hops and visible streamers off the crests.
  playful: { wind: 68, hop: 128, grain: 1.4 },
};

const GRAVITY = 420;
/** tan(34°) — dry sand stops sliding at about this steepness. */
const REPOSE = 0.674;
/** How strongly a windward slope throws a landing grain onward. */
const LEE_BIAS = 3;

type Grain = { x: number; y: number; vx: number; vy: number };

export default function SandFlow({
  variant = "default",
  count = 200,
  grainColor = "#DCCBA8",
  driftColor = "#C3B08D",
}: SandFlowProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let width = 0;
    let height = 0;
    let cellWidth = 4;
    let columns = 1;
    let heights: number[] = [];
    let grains: Grain[] = [];
    let elapsed = 0;

    /** Volume one grain carries, in column height. Small on purpose. */
    const VOLUME = 0.7;

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    const surfaceAt = (x: number) => {
      const column = Math.floor(x / cellWidth);
      const index = ((column % columns) + columns) % columns;
      return height - heights[index];
    };

    const columnAt = (x: number) => {
      const column = Math.floor(x / cellWidth);
      return ((column % columns) + columns) % columns;
    };

    /**
     * Take a grain's worth off the surface and throw it back into the
     * wind. The pick is uniform on purpose: biasing it toward the
     * tallest column erodes the crests as fast as the lee side builds
     * them, and the whole field flattens out within seconds.
     */
    const lift = (): Grain => {
      let pick = Math.floor(Math.random() * columns);
      // Bare ground between drifts is real, but taking sand out of it
      // would invent mass at the floor. Look elsewhere instead.
      for (let attempt = 0; attempt < 8 && heights[pick] < VOLUME; attempt++) {
        pick = Math.floor(Math.random() * columns);
      }
      if (heights[pick] < VOLUME) {
        for (let index = 0; index < columns; index++) {
          if (heights[index] > heights[pick]) pick = index;
        }
      }
      heights[pick] -= VOLUME;
      return {
        x: pick * cellWidth + random(0, cellWidth),
        y: height - heights[pick] - random(0, 2),
        vx: config.wind * random(0.7, 1.35),
        vy: -config.hop * random(0.55, 1.15),
      };
    };

    const reset = () => {
      cellWidth = 4;
      columns = Math.max(8, Math.ceil(width / cellWidth));
      // Start with the mass already on the ground: a low, uneven bed the
      // wind can shape, rather than a flat floor waiting to be built.
      heights = Array.from(
        { length: columns },
        (_, index) =>
          height *
          (0.05 +
            0.02 * Math.sin((index / columns) * Math.PI * 4) +
            0.012 * Math.sin((index / columns) * Math.PI * 11 + 1.3) +
            Math.random() * 0.01)
      );
      grains = Array.from({ length: count }, lift);
    };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.floor(width * ratio));
      canvas.height = Math.max(1, Math.floor(height * ratio));
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
    };

    /** The angle of repose, applied as two relaxation sweeps per frame. */
    const relax = () => {
      const limit = REPOSE * cellWidth;
      for (let pass = 0; pass < 2; pass++) {
        for (let index = 0; index < columns; index++) {
          const next = (index + 1) % columns;
          const difference = heights[index] - heights[next];
          if (difference > limit) {
            const move = (difference - limit) * 0.5;
            heights[index] -= move;
            heights[next] += move;
          } else if (difference < -limit) {
            const move = (-difference - limit) * 0.5;
            heights[index] += move;
            heights[next] -= move;
          }
        }
      }
    };

    const step = (delta: number) => {
      elapsed += delta;
      // One shared gust, so the whole stream surges together instead of
      // each grain deciding for itself.
      const gust = 1 + Math.sin(elapsed * 0.27) * 0.22 + Math.sin(elapsed * 0.61) * 0.1;

      for (let index = 0; index < grains.length; index++) {
        const grain = grains[index];
        grain.vy += GRAVITY * delta;
        grain.x += grain.vx * gust * delta;
        grain.y += grain.vy * delta;

        if (grain.x < 0) grain.x += width;
        if (grain.x >= width) grain.x -= width;

        if (grain.y >= surfaceAt(grain.x)) {
          const column = columnAt(grain.x);
          const upwind = (column - 1 + columns) % columns;
          // Positive slope means the ground is climbing into the wind:
          // an exposed windward face, where a grain is most likely to be
          // knocked onward. A downslope is the sheltered lee, and there
          // it always stays. This asymmetry is what builds the drifts.
          const slope = (heights[column] - heights[upwind]) / cellWidth;
          const stick = slope <= 0 ? 1 : Math.max(0.05, 1 - slope * LEE_BIAS);

          if (Math.random() < stick) {
            // Its volume joins the drift, and the same volume is lifted
            // somewhere else, so nothing is created or lost.
            heights[column] += VOLUME;
            grains[index] = lift();
          } else {
            // Bounced on: a shorter hop, still carried by the wind.
            grain.y = height - heights[column] - 1;
            grain.vy = -config.hop * random(0.3, 0.6);
            grain.vx = config.wind * random(0.7, 1.2);
          }
        }
      }

      relax();
    };

    const render = () => {
      context.clearRect(0, 0, width, height);

      // The drift, as one filled path along the height field.
      context.beginPath();
      context.moveTo(0, height);
      for (let index = 0; index < columns; index++) {
        context.lineTo(index * cellWidth, height - heights[index]);
      }
      context.lineTo(width, height - heights[columns - 1]);
      context.lineTo(width, height);
      context.closePath();
      context.fillStyle = driftColor;
      context.globalAlpha = 0.9;
      context.fill();
      // A lit top edge: without it the profile is a silhouette and the
      // repose angle is much harder to read.
      context.strokeStyle = grainColor;
      context.globalAlpha = 0.5;
      context.lineWidth = 1;
      context.stroke();

      context.fillStyle = grainColor;
      context.globalAlpha = 0.85;
      const size = config.grain;
      for (const grain of grains) {
        context.fillRect(grain.x, grain.y, size, size);
      }
      context.globalAlpha = 1;
    };

    resize();
    reset();
    // Run the simulation forward before the first frame, so the field
    // opens with drifts already formed rather than a flat bed.
    for (let index = 0; index < 600; index++) step(1 / 60);

    // Reduced motion: the same simulation, taken further and then held.
    // The drifts and their constant-angle flanks are the subject, and
    // they are all still there in a single frame.
    if (reduced) {
      for (let index = 0; index < 900; index++) step(1 / 60);
      render();
      const onResizeStill = () => {
        resize();
        reset();
        for (let index = 0; index < 1200; index++) step(1 / 60);
        render();
      };
      window.addEventListener("resize", onResizeStill);
      return () => window.removeEventListener("resize", onResizeStill);
    }

    let frame = 0;
    let last = performance.now();

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      step(delta);
      render();
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      resize();
      reset();
      for (let index = 0; index < 600; index++) step(1 / 60);
    };
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, count, grainColor, driftColor]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      style={{ width: "100%", height: "100%", display: "block" }}
    />
  );
}

About this effect

A quiet floor for an empty state or a waiting screen. A grain landing on the sheltered lee side sticks; a grain landing while climbing the windward face usually bounces on. That asymmetry is the only reason drifts exist at all — deposit grains evenly and the surface can only flatten, because flat is the single stable state, while a lee that traps them lets any small bump collect more than it loses. Erosion is uniform across the surface for the same reason in reverse: lifting grains preferentially from the tallest column shaves the crests as fast as the lee builds them. A second rule gives the drifts their shape, relaxing the height field to the angle of repose so nothing stands steeper than about thirty-four degrees, which is why real sand has flanks at a constant angle and a heap built without it reads as dough. Mass is conserved exactly, and the simulation is run forward before the first frame so the field opens with its drifts already formed.

Empty state floorWaiting screenDesert or travel themeSlow ambient footer

Related effects