dotwave
v0.1.0
Published
Animated dotted background driven by 3D Perlin noise. Zero dependencies, TypeScript, ESM + CJS + CDN.
Maintainers
Readme
dotwave
A grid of dots that breathe, driven by 3D Simplex noise. Zero dependencies · TypeScript · ESM + CJS + CDN · ~5 KB gzipped · 60 fps at 4K.
Each dot's opacity and size come from a 3D noise field sampled at
noise(x * noiseScale, y * noiseScale, elapsed * speed). That is what makes the
dots appear in organic waves that grow and fade instead of following a rigid
loop, and it is one small function call per dot per frame.
import { createDotWave } from "dotwave";
const wave = createDotWave({ container: "#hero", preset: "aurora", angle: 30 });
wave.updateOptions({ interactive: true });
wave.destroy();Install
npm install dotwave
# or
pnpm add dotwaveNo runtime dependencies. Ship it as ESM, CJS, or straight from a CDN:
<script src="https://unpkg.com/dotwave/dist/index.global.js"></script>
<script>
DotWave.createDotWave({ preset: "ember" });
</script>Types are included.
Quick start
import { createDotWave } from "dotwave";
// A container element (or selector) to fill. Defaults to document.body.
const wave = createDotWave({
container: "#hero",
preset: "aurora",
interactive: true,
angle: 15,
});
// Live updates, no re-creation needed.
wave.updateOptions({ speed: 0.002, dotColor: "167, 139, 250" });
wave.pause();
wave.play();
wave.destroy();Plain HTML, no build step:
<script src="https://unpkg.com/dotwave/dist/index.global.js"></script>
<script>
DotWave.createDotWave({ container: document.body, preset: "starfield" });
</script>run pnpm demo after pnpm build to open the full playground with live
controls, presets and an fps counter.
Presets
Six ready-made looks. Anything you pass alongside preset wins over it.
| Preset | Look |
| ----------- | ----------------------------------------------------- |
| aurora | Slow mint clouds, reacts to the pointer |
| starfield | Tiny crisp dots twinkling on near-black |
| nebula | Wide violet haze, very slow |
| ember | Warm square embers drifting up |
| radar | Hollow rings on a grid tilted 15° |
| minimal | Almost invisible, for readable content on top |
import { createDotWave, presets, presetNames } from "dotwave";
presetNames; // ["aurora", "starfield", "nebula", "ember", "radar", "minimal"]
presets.nebula;Options
Everything is optional. Invalid numbers are clamped instead of breaking the render loop.
| Option | Type | Default | What it does |
| ------------------- | ----------------------------- | -------------------- | ---------------------------------------------------------------- |
| container | HTMLElement \| string | document.body | Element or selector that hosts the canvas |
| spacing | number | 22 | Distance between dots in px |
| dotRadius | number | 1.2 | Base radius in px, minimum 1 |
| dotColor | string | "255, 255, 255" | "r, g, b", "rgb(...)", "#fff" or "#ffffff" |
| noiseScale | number | 0.0025 | Spatial frequency. Lower = bigger, slower blobs |
| speed | number | 0.0008 | Noise units per ms. 0 = frozen frame |
| backgroundColor | string | "#121316" | Canvas background. "transparent" shows the page through |
| interactive | boolean | false | React to the pointer |
| angle | number | 0 | Rotates the whole dot grid, in degrees |
| shape | "dot" \| "ring" \| "square" | "dot" | Geometry of each dot |
| sizeVariation | number | 0 | Noise also drives dot size. 1 = radius between 0x and 2x. Sub-pixel dots are skipped |
| gamma | number | 2.5 | Contrast curve. Higher keeps only the peaks |
| intensity | number | 0.7 | Maximum opacity |
| seed | number | 1337 | Same seed, same pattern |
| opacityThreshold | number | 0.03 | Dots below this are not drawn at all |
| pointerRadius | number | 180 | Influence area in px |
| pointerStrength | number | 0.9 | How strongly the pointer affects dots |
| pointerRepel | number | 10 | Pixels pushed away from the cursor. Negative pulls them in |
| autoPause | boolean | true | Pause on document.hidden, resume on return |
| position | "absolute" \| "fixed" | "absolute" | CSS position of the canvas |
| zIndex | number | 0 | Use -1 to sit behind your content |
| preset | string | — | Built-in style to start from |
Methods
| Method | Description |
| ----------------------------- | ------------------------------------------------- |
| init() | Starts the loop. createDotWave() calls it for you |
| updateOptions(options) | Applies changes live, no re-creation |
| pause() / play() | Stops / resumes the loop |
| destroy() | Cancels rAF, removes listeners, detaches the canvas |
| running | Whether the loop is running |
| canvas | The underlying HTMLCanvasElement |
Angled grids
angle turns the whole lattice, not just the dots: the canvas is rotated once
around its centre and the grid is walked over the rotated bounding box, so no
corner is left empty.
createDotWave({ angle: 45 }); // diagonal grid
createDotWave({ angle: 90 }); // same grid, quarter turnThe pointer position is rotated into grid space, so the reaction still lands exactly under the cursor at any angle.
How it stays at 60fps
Every preset holds 60 fps on a 3840x2160 canvas, measured in Chromium on an
RTX 4060, including spacing: 12 (which the dot budget stretches) and
angle: 45. Same at 1440x900.
The interesting part is what was removed. Each of these was measured, not guessed, and all of them were catastrophic at 4K:
- No
shadowBlur. A blurred shadow rasterised per dot costs ~18 ms per frame on a 4K canvas — the whole budget for the glow alone. That is what made the first version run at 7 fps on a 4K screen. There is no glow option any more: every cheap alternative measured twice the frame time. - No canvas-wide paths.
fill()on a path that spans the canvas makes the browser rasterise the full surface, ~4 ms per fill at 4K. Grouping dots into 24 opacity buckets that way is ~100 ms per frame. So each dot is its own tiny path: ~10 ms for the same grid. - No
moveTo()beforearc(). Left over from the bucketed version, it looks harmless and measured 9 fps against 59: it stops the path being a plain circle the rasteriser can blit. - No sub-pixel dots. A grid of radius-0.9 dots runs at 17 fps; radius 1.0 at
60.
dotRadiusis clamped to a minimum of 1, and dots thatsizeVariationshrinks below that are skipped — they are the faintest ones of the wave anyway.
And what kept:
- Noise is vendored. ~2 KB of seeded 3D Simplex noise, no dependency, and
seedmakes the pattern reproducible. Sampling costs ~1.5 ms per frame at 4K, so the canvas is where the budget goes. - Pre-rendered colours. The classic implementation builds an
rgba(...)string for every dot on every frame. dotwave pre-renders 24 of them once and indexes into them. - A dot budget. Frame time is proportional to dot count, so anything past
12,000 dots stretches its own
spacingand warns once, instead of dropping frames. That is whyspacing: 12is not a footgun on a 4K screen. - Guards. Dots below
opacityThresholdare never drawn. - Hidden tab costs nothing.
document.hiddenpauses the loop, and the time step is capped so returning to the tab does not jump.
Frameworks
There is no framework binding on purpose: one useEffect covers all of them.
// React
import { useEffect, useRef } from "react";
import { createDotWave } from "dotwave";
export function DotWaveBackground() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const wave = createDotWave({ container: ref.current, preset: "aurora" });
return () => wave.destroy(); // cleanup on unmount
}, []);
return <div ref={ref} style={{ position: "fixed", inset: 0, zIndex: -1 }} />;
}Development
pnpm install
pnpm test # 44 tests, jsdom canvas mock
pnpm typecheck
pnpm build # ESM + CJS + minified CDN build + types
pnpm size # raw / gzip / brotli per file
pnpm demo # static server for examples/Ideas that are deliberately not built yet live in docs/IDEAS.md.
License
MIT
