Fog Drift
Soft banks of fog sliding sideways, thinning out before they reach the edges.
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.
import { useEffect, useRef } from "react";
/**
* Vibary · Fog Drift
*
* Soft banks of fog sliding sideways and thinning out at the edges.
*
* The technique that makes it fog rather than a row of blurry circles:
* no single blob is visible on its own. Each one is drawn at an opacity
* low enough to be almost nothing, and the fog exists only where several
* of them overlap. Because they drift at different speeds by depth, the
* overlaps form and part on their own, so density varies continuously
* without anything animating density directly — and there is never a
* blob edge to catch the eye.
*
* Supporting detail: one radial gradient is built and re-used under a
* scale transform, so the blob count costs nothing in allocation.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FogDriftProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Banks of fog on screen. Each one is nearly invisible alone. */
count?: number;
/** Fog colour, as `#rgb` or `#rrggbb`. */
color?: string;
};
type VariantConfig = {
/** Multiplier applied to `count`. */
density: number;
/** Multiplier on sideways drift speed. */
speed: number;
/** Multiplier on bank size. */
size: number;
/** Multiplier on per-bank opacity. */
weight: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A haze you notice only when you look for it.
subtle: { density: 0.7, speed: 0.55, size: 0.9, weight: 0.7 },
// Reads as weather without swallowing the content. All-purpose.
default: { density: 1, speed: 1, size: 1, weight: 1 },
// Thicker and moving: fog with a wind behind it.
playful: { density: 1.3, speed: 1.7, size: 1.15, weight: 1.2 },
};
type Bank = {
x: number;
y: number;
radius: number;
/** Sideways speed in px per second. Depth, effectively. */
speed: number;
alpha: number;
/** Phase of the slow vertical settle. */
phase: number;
bob: number;
};
/** Base opacity of one bank. Deliberately far too low to see alone. */
const BANK_ALPHA = 0.09;
/** `#rgb` or `#rrggbb` to channels, so stops can end at zero alpha. */
function parseHex(hex: string): { r: number; g: number; b: number } {
const value = hex.replace("#", "");
const full =
value.length === 3
? value
.split("")
.map((char) => char + char)
.join("")
: value;
const number = parseInt(full, 16);
return { r: (number >> 16) & 255, g: (number >> 8) & 255, b: number & 255 };
}
export default function FogDrift({
variant = "default",
count = 14,
color = "#9FADBA",
}: FogDriftProps) {
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 total = Math.max(3, Math.round(count * config.density));
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let ratio = 1;
let banks: Bank[] = [];
// One gradient in unit space, re-used for every bank under a scale
// transform. Building a gradient per bank per frame is the usual way
// this pattern gets slow.
const tint = parseHex(color);
const rgba = (alpha: number) => `rgba(${tint.r}, ${tint.g}, ${tint.b}, ${alpha})`;
const soft = context.createRadialGradient(0, 0, 0, 0, 0, 1);
soft.addColorStop(0, rgba(1));
soft.addColorStop(0.42, rgba(0.5));
// Ends at the same colour with no alpha, so the rim fades out
// rather than darkening on its way to transparent.
soft.addColorStop(1, rgba(0));
const resize = () => {
const rect = canvas.getBoundingClientRect();
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);
};
const random = (min: number, max: number) => min + Math.random() * (max - min);
const build = () => {
const scale = Math.max(width, height);
banks = Array.from({ length: total }, () => {
const depth = Math.random();
return {
x: random(-0.2, 1.2) * width,
y: random(0.1, 0.9) * height,
radius: scale * random(0.26, 0.46) * config.size * (0.7 + depth * 0.6),
// Near banks slide faster, which is what separates the layers.
speed: (8 + depth * 26) * config.speed,
alpha: BANK_ALPHA * config.weight * (0.7 + depth * 0.6),
phase: random(0, Math.PI * 2),
bob: random(4, 14),
};
});
};
const drawFrame = (elapsed: number) => {
context.clearRect(0, 0, width, height);
context.fillStyle = soft;
for (const bank of banks) {
const y = bank.y + Math.sin(elapsed * 0.08 + bank.phase) * bank.bob;
context.globalAlpha = bank.alpha;
context.save();
context.translate(bank.x, y);
context.scale(bank.radius, bank.radius);
context.fillRect(-1, -1, 2, 2);
context.restore();
}
// Thin out at the left and right edges, so the fog has no seam
// where it meets the frame. Erasing afterwards is exact whatever
// the banks happen to be doing.
context.globalAlpha = 1;
context.globalCompositeOperation = "destination-out";
const margin = Math.max(24, width * 0.2);
const left = context.createLinearGradient(0, 0, margin, 0);
left.addColorStop(0, "rgba(0,0,0,1)");
left.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = left;
context.fillRect(0, 0, margin, height);
const right = context.createLinearGradient(width, 0, width - margin, 0);
right.addColorStop(0, "rgba(0,0,0,1)");
right.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = right;
context.fillRect(width - margin, 0, margin, height);
context.globalCompositeOperation = "source-over";
};
resize();
build();
// Reduced motion: one frame of the fog. The overlaps that make the
// density are already there in any single frame, so a still holds
// the whole picture.
if (reduced) {
drawFrame(0);
const onResizeStill = () => {
resize();
build();
drawFrame(0);
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let last = performance.now();
let elapsed = 0;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
for (const bank of banks) {
bank.x += bank.speed * delta;
if (bank.x - bank.radius > width) bank.x = -bank.radius;
}
drawFrame(elapsed);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
build();
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block", pointerEvents: "none" }}
/>
);
}About this effect
Atmosphere for a screen that should feel like a place — a map at dawn, a locked screen, the header of a quiet reading app. No single bank is visible on its own: each is drawn at an opacity low enough to be almost nothing, and the fog exists only where several overlap. Because they drift at different speeds by depth, those overlaps form and part on their own, so density varies continuously without anything animating density directly, and there is never a blob edge to catch the eye. One radial gradient is built and re-used under a scale transform, and the left and right margins are erased afterwards so the fog has no seam where it meets the frame.