Resources
Troubleshooting
The handful of things that trip people up — each with the why and the fix. Most reduce to one of two facts: the field reads bodies on a schedule, and it refuses to fake motion.
New elements aren't reacting
The field scans for [data-body] elements when it mounts. If you add or remove
bodies afterwards — a route change, a list update, a modal — it won't see them until you
re-scan. Call field.scan() (or its alias rescan()) after the DOM
settles.
// plain DOM / web component
field.scan();
// React — bodies render after the field mounts, so scan from onReady
<FieldField onReady={(field) => field.scan()} /> The field is frozen or blank
Two behaviours pause the loop on purpose — both are features, not bugs:
- Reduced motion. Under
prefers-reduced-motion: reducethe simulation renders a single static frame (dt = 0) and never animates. Test with the OS setting off to see motion. - Backgrounded tab. The loop stops on
visibilitychangeand resumes when the tab is foregrounded again, so a hidden tab costs nothing. - Off-screen cells. A
<field-cell>gates its loop on anIntersectionObserver— it only runs while visible.
The canvas covers my content or eats clicks
With <field-root> and the React component this is handled for you. If
you own the canvas via createField, position it fixed behind your content and
let pointer events pass through:
/* the canvas is decorative — behind content, never intercepting clicks */
.forces-canvas {
position: fixed;
inset: 0;
z-index: 0; /* your content sits above */
pointer-events: none;/* clicks pass through */
} <field-root> elements — for small in-frame effects use
<field-cell> instead, which is built to be embedded many times.
--field-density isn't updating my element
Density write-back is opt-in. The element needs data-feedback, and your CSS
should default --field-density (via the var(--field-density, 0)
fallback) so it renders before the first write. The value is low-pass filtered (~0.2s), so it
eases rather than jumps. (--field-density is the field-namespaced form of the
canonical raw channel --d; both hold the same value.)
<!-- 1. opt the element in -->
<h1 data-body="attract" data-range="320" data-feedback>weighty</h1>
<style>
h1 {
/* the engine eases this in ∈ [0,1]; default it yourself so it renders before
the first write. --d is the canonical raw density channel; --field-density is
its field-namespaced form. */
font-variation-settings: 'wght' calc(380 + var(--field-density, 0) * 420);
}
</style> --field-density drives
weight, glow, and scale on the real element.
SSR & hydration
The field is a client-only concern. The custom element registers in the browser, and the
React component mounts its canvas on the client — there is no server-rendered canvas to
mismatch. In Astro, the import '@fundamental-engine/elements' lives in a client
<script>; in Next.js, render the field in a client component. Bodies are
plain markup, so they server-render fine; only the field's reaction is deferred to the
client.
The field keeps running after I remove it
A field owns real resources — a running loop, event listeners, an idle timer, and (depending on the surface) a canvas, GPU buffers, and registry observers. How they're released depends on how you mounted it:
-
<field-root>/<field-cell>— automatic. The element tears itself down on disconnect (and rebuilds cleanly on an attribute change): the loop stops, the platform registries are pruned, and the visual-binding observers disconnect. Just remove the element. -
createField()/ the Three.jsFieldLayer— you hold the handle, so you calldestroy()when the view goes away. It stops the loop, releases the host's listeners, clears the idle timer, retires agents, and frees the swarm's GPU buffers. Skipping it on a re-mounted or route-swapped view is the usual cause of a slow leak.
// <field-root> — automatic: it tears down on disconnect / attribute rebuild
el.remove(); // registries pruned, observers disconnected, timers cleared
// createField / FieldLayer — you own the handle, so you call destroy()
const field = createField(canvas, opts);
// …later, when the view goes away:
field.destroy(); // stops the loop, releases listeners, frees GPU + timers It feels heavy on a large page
The field is one shared canvas, so cost does not grow with the number of bodies beyond the
per-body loop term. If a very large or low-power surface needs headroom, lower
density — density: 0.5 halves the particle
count. See Performance for the full picture.