Core Concepts
The architectural decisions behind liquiddom — what makes it small, fast, and accessible by construction.
The Rule of Two
Rust is DOM-blind and color-blind. TypeScript owns the DOM and rendering. Communication is one-way through a pre-allocated flat buffer.
┌─────────────────────────────────────┐
│ TypeScript (DOM-aware) │
│ - PhantomObserver: reads rects │
│ - RAF loop │
│ - Pointer/scroll/visibility events │
│ - Renderer (Canvas2D or WebGPU) │
└─────────┬───────────────────────────┘
│ flat Float32Array (9 floats/entity)
│ NO JSON, NO setters
┌─────────▼───────────────────────────┐
│ Rust → WASM (DOM-blind) │
│ - Physics: mass-spring-damper │
│ - Particle constraints │
│ - Area preservation │
└─────────────────────────────────────┘ The FFI buffer (9 floats per entity)
A single Float32Array in WASM
linear memory. No JSON crosses the boundary. Slot indices are stable:
| Index | Field | Writer |
|---|---|---|
| 0 | x (DOM left) | TypeScript |
| 1 | y (DOM top) | TypeScript |
| 2 | w | TypeScript |
| 3 | h | TypeScript |
| 4 | interaction_state | TypeScript |
| 5 | liquid_type | TypeScript |
| 6 | impulse_vx | TypeScript |
| 7 | impulse_vy | TypeScript |
| 8 | border_radius_px | TypeScript |
w == 0 means "slot inactive" — Rust skips that
entity in tick(). The constants
FLOATS_PER_ENTITY and
PARTICLES_PER_BODY are duplicated in
Rust and TypeScript and MUST stay in sync.
Per-frame loop ordering
Order matters. Sync first, tick second, render third.
- Cache
getBoundingClientRectonce; set coord offset for container mode - Set canvas DPR via
setTransform(never cumulative) ctx.clearRectobserver.sync()— DOM rect → buffercore.tick(dt, ...)— physics step (paused under scroll or reduced-motion)observer.render(...)— particles → canvas splines
liquid_type dispatch
Slot 5 of the buffer selects the per-entity behavior:
| Value | Name | Behavior |
|---|---|---|
| 0 | Default | Soft-body following DOM rect |
| 3 | Dragged | Drag the element; physics follows |
| 4 | Shake | Vibrates after impulse() |
| 5 | Tween | External position target |
| 6 | FreeDrop | DOM-less droplet particles |
NaN and unknown values fall back to Default.
The rendering backend
renderer: 'auto' (default) probes
WebGPU and falls back to Canvas2D on
WebGPUUnavailableError.
Toggle it globally via the segmented control in the page header — the
choice persists in localStorage.
- Canvas2D path: universal browser support, Bezier-spline blobs, no fusion or refraction.
- WebGPU path: Chrome 113+, Edge 113+. SDF blobs, metaball fusion, background refraction.