@solitti/field
v0.3.0
Published
Particle field on canvas. Modes decide position authority, drivers are summed forces.
Readme
@solitti/field
Particle field on canvas: physics-based motion with a composable driver model. Zero framework dependencies.
Installation
Packages are published to GitHub Packages (private). Add a .npmrc to your project:
@solitti:registry=https://npm.pkg.github.comThen install:
npm install @solitti/fieldQuick start
React
Use @solitti/field-react for a one-liner hook API.
Vanilla JS: drop into a div
import { mountField } from "@solitti/field";
const { dispose } = mountField(document.querySelector("#bg"), {
scene: "drift",
seed: 7,
});
// tear down later
dispose();mountField creates a <canvas> inside the target element, starts the field, and returns { field, canvas, dispose }. Pass an existing <canvas> as the target and mountField adopts it instead (dispose leaves it in the DOM).
Direct canvas control
import { Field } from "@solitti/field";
const canvas = document.querySelector("canvas");
const field = new Field(canvas, { scene: "drift", seed: 7 });
// later
field.destroy();DRIFT presets
Ready-made configurations that reproduce maintenance.html behavior:
import { DRIFT, Field } from "@solitti/field";
// Pre-built (default params)
const field = new Field(canvas, DRIFT.SATELLITES); // 98 orange squares
const field = new Field(canvas, DRIFT.DUST); // 150 fine circles
const field = new Field(canvas, DRIFT.EMBER); // 48 sparse squares
// Factories (custom count + seed)
const field = new Field(canvas, DRIFT.satellites(120, 42));
const field = new Field(canvas, DRIFT.dust(200, 13));
const field = new Field(canvas, DRIFT.ember(60, 99));Scenes
Pass scene to pick a named preset driver stack:
| Scene | Behaviour |
|-------|-----------|
| "drift" | Seeded organic drift, the maintenance.html preset |
| "lattice" | Spring-attracted grid |
| "form" | Spring-attracted square outline |
| "restore" | Grid + pointer repel |
| "levy" | Lévy flight random walk |
| "brownian" | Brownian motion |
| "chemotaxis" | Self-aggregating swarm |
Field API
class Field {
constructor(canvas: HTMLCanvasElement, opts?: FieldOptions)
// Live configuration
configure(o: ConfigInput): this // replace drivers / particle
retarget(o: ConfigInput): this // keep physics state, swap target
setParticle(p: Partial<Particle>): this
setCount(n: number): this
setMode(m: "dynamics" | "timeline"): this
setProgress(p: number): this // timeline scrub [0, 1]
setTimeline(p: number): this // alias for setProgress
scatter(): this // randomise positions
disturb(magnitude: number): this // impulse kick
// Manual frame (when constructed with { ticker: false })
tick(dt: number): void
// Instrumentation (read-only, observable; no driver internals)
stats(): FieldStats
probe(i: number, out?: ProbeResult): ProbeResult
destroy(): void // remove listeners, disconnect ResizeObserver, stop ticker
}FieldOptions
All SimulationOptions plus:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| ticker | Ticker \| false | shared | BYO ticker or false for manual tick() |
| onFps | (n: number) => void | none | FPS callback (fires every ~500 ms) |
SimulationOptions
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| units | number | 484 | Particle count |
| width | number | 800 | Canvas logical width (px) |
| height | number | 600 | Canvas logical height (px) |
| seed | number | 0 | RNG seed for scatter and drift |
| scene | string | none | Named preset (see table above) |
| mode | "dynamics" \| "timeline" | "dynamics" | Position authority |
| reduced | boolean | none | Freeze motion (reduced-motion) |
| particle | Partial<Particle> | none | Shape, color, size overrides |
| drivers | Driver[] | none | Explicit driver list (overrides scene) |
| scatterMode | "ring" \| "fill" \| "maintenance" | "ring" | Initial scatter distribution |
Backdrop contract
The field renders transparent, clearRect on every frame. Drift particles have alpha as low as 0.18, which is legible only against a dark ground. This matches maintenance.html, which supplies html { background: oklch(0.13 0.008 152) } in CSS.
When used as a transparent overlay, the host element's background serves as the ground (correct). When the field needs to be self-contained, standalone or on an unknown background, supply the ground via the background particle option:
// Self-contained field: carries its own dark ground
new Field(canvas, {
scene: "drift",
seed: 7,
particle: { background: "oklch(0.13 0.008 152)" },
});background defaults to unset (transparent). Existing overlay usage is unaffected.
Particle options
field.setParticle({
shape: "circle", // "square" | "circle" | "ring" | "triangle" | "diamond" | "plus"
size: 3, // base size (px)
color: "#ff4d00", // settled color
colorRest: "rgba(255,77,0,0.18)", // unsettled / roaming color
spacing: 1.2, // lattice spacing multiplier
background: "oklch(0.13 0.008 152)", // optional opaque ground; unset = transparent
});Drivers
Compose motion by listing drivers explicitly. Drivers are summed: a particle's position is the result of all active drivers:
new Field(canvas, {
drivers: [
{ type: "attract", to: "lattice", stiffness: 90, damping: 0.9 },
{ type: "repel", from: "pointer", strength: 2400, radius: 120 },
],
});| Driver type | Key options | Description |
|-------------|-------------|-------------|
| attract | to, stiffness, damping | Spring-pull to a geometry |
| repel | from: "pointer", strength, radius | Push away from pointer |
| wander | process, speed | Random walk (brownian/levy/crw/chemotaxis/flow) |
| drift | seed, params | Seeded organic drift (maintenance.html physics) |
Timeline mode
mode: "timeline" makes position a pure, reversible function of progress p ∈ [0, 1]:
const field = new Field(canvas, {
mode: "timeline",
keyframes: [
{ at: 0, to: "point" },
{ at: 0.5, to: "lattice" },
{ at: 1, to: "square" },
],
});
// drive from scroll position
document.addEventListener("scroll", () => {
field.setProgress(window.scrollY / (document.body.scrollHeight - window.innerHeight));
});Sizing
Field reads the canvas element's bounding box via getBoundingClientRect and tracks changes with ResizeObserver. Make the canvas fill its container with CSS and it will resize automatically, no JS needed:
<div style="position:relative; width:100%; height:400px;">
<canvas style="position:absolute; inset:0; width:100%; height:100%;"></canvas>
</div>Reduced motion
Field checks prefers-reduced-motion at construction and freezes drift animations automatically. No handling needed in application code.
Instrumentation
Field and Simulation expose a read-only surface for debugging and live dashboards.
stats()
Returns aggregate metrics over all active particles for the last completed frame:
interface FieldStats {
count: number; // total particles
activeCount: number; // particles currently active
speed: Range; // px/s, wrap-suppressed
alpha: Range;
size: Range; // px (full side length)
bounds: { minX: number; maxX: number; minY: number; maxY: number };
wrappedCount: number; // particles that crossed a seam this frame
}
interface Range { min: number; max: number; mean: number; }All aggregates are over active particles only. Speed is derived from successive positions with wrap suppression, a particle crossing the canvas seam contributes speed = 0 for that frame.
stats() is O(n); throttle to ≤ 10 Hz for UI use (see mountInsights and useFieldStats).
probe(i)
Per-particle read with wrap-aware velocity:
probe(i: number, out?: ProbeResult): ProbeResultO(1). Pass an out object to avoid allocation in hot paths. Throws RangeError for out-of-range i.
Pure functions
For use outside a Field instance, for example to instrument a custom canvas loop:
import { computeStats, probeAt } from "@solitti/field";
const stats = computeStats(view, dt); // same computation as field.stats()
const p = probeAt(view, i, dt); // same as field.probe(i)Insights overlay
mountInsights mounts a default-off diagnostic card that displays live FieldStats:
import { mountField, mountInsights } from "@solitti/field";
const { field, dispose } = mountField(document.querySelector("#bg"), { scene: "drift" });
const card = mountInsights(document.body, () => field.stats());Press i to toggle. Returns an InsightsHandle:
interface InsightsHandle {
readonly visible: boolean;
show(): void;
hide(): void;
dispose(): void; // removes the element + key listener
}Optional { hz } (default 4) controls the poll rate.
mountField
function mountField(target: HTMLElement, opts?: FieldOptions): FieldHandle;
interface FieldHandle {
field: Field;
canvas: HTMLCanvasElement;
dispose: () => void;
}- Target is a
<canvas>→ adopted.dispose()tears down Field only; canvas stays in DOM. - Target is anything else → a canvas is created, appended, and styled
display:block; width:100%; height:100%.dispose()also removes the canvas. dispose()is idempotent.- Throws
"mountField requires a DOM"when called server-side.
