svelte-fluid
Fluid simulation for Svelte 5.
Why svelte-fluid?
Most fluid packages are vanilla JS with a Svelte wrapper bolted on.
This one is built for Svelte 5 from the ground up:
- True component API —
<Fluid /> with 70+ typed props, live reactive updates, and full cleanup on unmount
- Multiple independent instances per page — no shared GL state
- State-preserving resize — live dye and velocity survive layout changes without shader recompilation
- 14 example presets — reference configurations showing the range of what's possible
- 5 container shapes — circle, frame, roundedRect, annulus, and arbitrary SVG paths / text via mask texture
- Glass post-processing — refraction, specular highlights, and chromatic aberration on any container shape
- Lazy loading + auto-pause — defer engine creation until viewport entry
- Interface primitives —
InkPaper, LiquidButton, LiquidSegmented, LiquidDropZone, LiquidCaustics, LiquidToggle and EnamelText: native controls with a shared-WebGL2 surface and plain-control fallback
- Imperative API —
splat() and randomSplats() via bind:this
Install
npm install svelte-fluid
# or
pnpm add svelte-fluid
# or
bun add svelte-fluid
Requires Svelte ≥ 5.
Quick start
<script lang="ts">
import { Fluid } from 'svelte-fluid';
</script>
<div style="width:100%; height:100vh">
<Fluid />
</div>
That's it. Canvas fills its parent, resize handled automatically.
For a first-run screen that shows motion immediately in a blank app,
start with a slightly brighter starter config:
<div style="width:100%; height:100vh">
<Fluid
seed={42}
initialSplatCount={20}
autoSplatRate={0.25}
autoSplatCount={2}
densityDissipation={0.35}
velocityDissipation={0.08}
curl={45}
splatRadius={0.35}
bloomIntensity={1.2}
sunraysWeight={0.6}
backColor={{ r: 6, g: 10, b: 26 }}
/>
</div>
Fixed dimensions
<Fluid width={400} height={300} />
Custom physics
<Fluid
curl={20}
splatRadius={0.5}
bloom={false}
shading
densityDissipation={0.4}
initialSplatCount={12}
seed={42}
/>
Imperative API via bind:this
<script lang="ts">
import { Fluid, type FluidHandle } from 'svelte-fluid';
let ref = $state<{ handle: FluidHandle } | undefined>();
</script>
<button onclick={() => ref?.handle.randomSplats(10)}>Splat!</button>
<Fluid bind:this={ref} />
Splash cursor
A full-viewport, transparent, pointer-following fluid cursor:
<script lang="ts">
import { Fluid } from 'svelte-fluid';
</script>
<div class="splash-cursor" aria-hidden="true">
<Fluid transparent pointerTarget="window" splatOnHover bloom={false} sunrays={false}
densityDissipation={2} velocityDissipation={2} pressure={0.1} curl={3} splatRadius={0.25} />
</div>
<style>
.splash-cursor { position: fixed; inset: 0; z-index: 9999; pointer-events: none; }
</style>
Or npx shadcn-svelte@latest add https://svelte-fluid.dev/r/splash-cursor.json.
See the recipe.
Browser compatibility
- WebGL 1 (with linear filtering) and WebGL 2 both work
- Tested on Chrome 120+, Firefox 121+, Safari 17+
- Mobile: works on iOS Safari 16+ and Chrome Android, with
lazy={true}
strongly recommended on dense pages
- Linear filtering fallback: when
OES_texture_float_linear is unavailable,
the engine drops shading, bloom, sunrays and clamps dyeResolution ≤ 512 —
see FluidEngine.initContext()
Components
Six fluid components (Fluid, FluidBackground, FluidReveal, FluidDistortion,
FluidStick, FluidText; see Props) and seven interface primitives.
The primitives are real native elements (button, radio group, file input,
switch, div, heading text) with a decorative WebGL2 surface. They share one WebGL2 context
per page, so they do not hit the browser's context cap. Without WebGL2 each
falls back to a plain, fully styled native control. All honour
prefers-reduced-motion and keep native keyboard, touch and pen behaviour.
Full props, live examples and contrast notes: docs/components.
| Component | Job | Minimal example |
|---|
InkPaper | Paper that takes watercolour from strokes | <InkPaper style="height: 240px"><h3>Notes</h3></InkPaper> |
LiquidButton | Primary action button with a liquid surface | <LiquidButton onclick={save}>Save</LiquidButton> |
LiquidSegmented | Single-choice radio group on a liquid lens | <LiquidSegmented name="r" legend="Range" options={[{ value: 'day', label: 'Day' }, { value: 'week', label: 'Week' }]} bind:value /> |
LiquidDropZone | File picker and drop target | <LiquidDropZone accept="image/*" onfiles={(files) => upload(files)} /> |
LiquidCaustics | Caustic light over live content | <LiquidCaustics tone="dark"><p>Content</p></LiquidCaustics> |
LiquidToggle | Native checkbox switch with a sliding liquid lens | <LiquidToggle bind:checked={on}>Notifications</LiquidToggle> |
EnamelText | Display heading in glazed enamel that gives under a press | <h2><EnamelText text="Harbour" /></h2> |
<script>
import { LiquidButton, LiquidToggle, EnamelText } from 'svelte-fluid';
let on = $state(false);
</script>
<LiquidButton onclick={() => console.log('saved')}>Save</LiquidButton>
<LiquidToggle bind:checked={on}>Notifications</LiquidToggle>
<h2><EnamelText text="Harbour" /></h2>
Props
All props are optional. CamelCase wraps the original SCREAMING_CASE
config from the upstream project.
| Prop | Type | Default | Notes |
|---|
width | number | — | CSS px. Omit to fill parent. |
height | number | — | CSS px. Omit to fill parent. |
maxPixelRatio | number | null | null | native DPR; pass a number (e.g. 2) to cap physical DPR |
seed | number | random | 32-bit uint; deterministic initial splats |
simResolution | number | 128 | velocity grid; rebuilds FBOs |
dyeResolution | number | 1024 | dye grid; rebuilds FBOs |
densityDissipation | number | 1 | hot; steady-state value |
initialDensityDissipation | number | (= densityDissipation) | hot; ramp start (see burn-in pattern) |
initialDensityDissipationDuration | number | 0 | seconds; duration of the linear ramp |
velocityDissipation | number | 0.2 | hot |
pressure | number | 0.8 | hot |
pressureIterations | number | 20 | hot |
autoPerformance | boolean | false | hot; sustained-load quality governor |
The component also forwards any standard <canvas> attributes
(class, style, aria-label, …) onto the underlying canvas via
...rest.
Presets
Fourteen reference configurations demonstrating different physics setups,
container shapes, and solver-native flow scenes. Useful as starting points for building your own:
| Component | Look |
|---|
<LavaLamp /> | Warm blobs in a glass vessel with rim refraction |
<Plasma /> | Plasma-inspired visual fluid with full-spectrum jets converging into a bright core |
<InkInWater /> | India ink sinking through dark water with volumetric bloom |
<FrozenSwirl /> | An icy whirlpool frozen inside a circular vessel |
<Aurora /> | Green, magenta, and pale-blue ribbons glowing like northern lights |
<Toroidal /> | Full-spectrum storm circulating in a high-velocity ring |
<CircularFluid /> | Vivid swirling fluid contained inside a circle |
<FrameFluid /> | Colorful fluid swirling around a rectangular inner cutout |
<AnnularFluid /> | Ring-vortex fluid between two concentric circles |
<SvgPathFluid /> | Fluid shaped by an ampersand glyph via mask texture |
<GasFlare /> | Temperature scalar, buoyancy, and physical nozzle obstructions |
<Venturi /> | Pressure-gradient throughflow with CFD-style speed visualization |
<Karman /> | Alternating cylinder-wake vortex street with multicolor tracers |
<TeslaValve /> | High-viscosity multicolor splats through an SVG conduit mask |
<script lang="ts">
import { LavaLamp, Plasma, Toroidal } from 'svelte-fluid';
</script>
<div style="height: 100vh">
<LavaLamp />
</div>
<div style="display:grid; grid-template-columns:repeat(2, 1fr); gap:16px">
<Plasma />
<Toroidal />
</div>
Each preset forwards a small common set of props: width, height,
class, style, seed, lazy, maxPixelRatio, maxFps, splatOnHover, aria-label, and
backColor. Flow-scene presets also expose pointerInput, and some shape
presets expose small shape-specific knobs such as corner radii. The main
physics recipe is intentionally fixed. They all re-expose the imperative
handle so you can still call splat() / randomSplats() from outside via
bind:this.
Building your own preset
A preset is a tiny wrapper around <Fluid /> that pins config and
optionally passes a presetSplats array. The engine consumes
presetSplats once at construction (right after the random initial
splats), so the opening scene is fully deterministic and reproducible
across resizes.
<script lang="ts">
import { Fluid, type PresetSplat, type FluidHandle } from 'svelte-fluid';
const SPLATS: PresetSplat[] = [
{ x: 0.5, y: 0.1, dx: 0, dy: 800, color: { r: 1.6, g: 0.4, b: 0.1 } }
];
let inner = $state<{ handle: FluidHandle } | undefined>();
export const handle: FluidHandle = {
splat: (x, y, dx, dy, c) => inner?.handle.splat(x, y, dx, dy, c),
randomSplats: (n) => inner?.handle.randomSplats(n),
pause: () => inner?.handle.pause(),
resume: () => inner?.handle.resume(),
get isPaused() { return inner?.handle.isPaused ?? true; },
getPerformanceState: () => {
if (!inner) throw new Error('Preset is not mounted');
return inner.handle.getPerformanceState();
}
};
</script>
<Fluid
bind:this={inner}
curl={50}
densityDissipation={0}
initialSplatCount={0}
presetSplats={SPLATS}
/>
Note: presetSplats is construct-only — like seed, changes after
mount are ignored (Bucket D). To paint a new scene, remount the wrapper
with a Svelte {#key sceneId} block and a new seed, or call
handle.splat() imperatively. Changing the seed prop alone does not rebuild.
Splat coordinates are normalized: x ∈ [0,1] left-to-right and
y ∈ [0,1] bottom-to-top. Color components are in 0–1 range; values
above 1 are valid and read as HDR highlights through the bloom pass.
Burn-in density dissipation
If you want a densityDissipation: 0 "vivid persistent" look but the
opening splats are bright enough to overwhelm the canvas, use a
temporary high dissipation that decays to zero:
<Fluid
densityDissipation={0}
initialDensityDissipation={1.5}
initialDensityDissipationDuration={2}
presetSplats={SPLATS}
/>
The engine linearly interpolates from initialDensityDissipation →
densityDissipation over initialDensityDissipationDuration seconds,
then holds at the steady-state value forever. This lets the overlapping
additive splats "burn in" — overbright pixels fade for the first couple
of seconds — before dissipation locks at zero so the remaining dye
persists indefinitely. The LavaLamp and Plasma presets
use this pattern.
The clock starts when the engine begins ticking (post-mount, post
first ResizeObserver fire), so the burn-in survives setConfig
updates and matches the user's perception of "since the canvas
appeared".
Container shapes
Confine the fluid to a geometric region with containerShape. The
simulation physically enforces the boundary — velocity is zeroed outside
and dye is masked after advection.
<!-- Circle -->
<Fluid containerShape={{ type: 'circle', cx: 0.5, cy: 0.5, radius: 0.45 }} />
<!-- Frame (fluid fills the border region around a rectangular cutout) -->
<Fluid containerShape={{ type: 'frame', cx: 0.5, cy: 0.5, halfW: 0.25, halfH: 0.25 }} />
<!-- Rounded rectangle -->
<Fluid containerShape={{ type: 'roundedRect', cx: 0.5, cy: 0.5, halfW: 0.35, halfH: 0.25, cornerRadius: 0.05 }} />
<!-- Annulus (ring between two circles) -->
<Fluid containerShape={{ type: 'annulus', cx: 0.5, cy: 0.5, innerRadius: 0.15, outerRadius: 0.4 }} />
<!-- SVG path -->
<Fluid containerShape={{ type: 'svgPath', d: 'M50 5 L61 40 L98 40 L68 62 L79 97 L50 75 L21 97 L32 62 L2 40 L39 40 Z' }} />
<!-- Text (fluid-filled letters) -->
<Fluid containerShape={{ type: 'svgPath', text: 'HELLO', font: 'bold 72px sans-serif' }} />
Coordinates are normalized: cx/cy in [0, 1] (left-to-right,
bottom-to-top). radius is normalized by canvas height. The svgPath
type rasterizes to a mask texture via OffscreenCanvas and supports
both SVG path data (d) and Canvas 2D text (text).
Glass effect
Add a glass post-processing layer to any container shape with glass.
Circles get a hemisphere dome with Snell's law refraction; all other
shapes get rim refraction at the boundary.
<!-- Glass orb with chromatic aberration -->
<Fluid
containerShape={{ type: 'circle', cx: 0.5, cy: 0.5, radius: 0.4 }}
glass
glassRefraction={0.6}
glassChromatic={0.3}
/>
<!-- Subtle glass frame -->
<Fluid
containerShape={{ type: 'frame', cx: 0.5, cy: 0.5, halfW: 0.3, halfH: 0.2 }}
glass
glassThickness={0.06}
glassReflectivity={0.08}
/>
The specular highlight tracks the mouse cursor automatically. All
highlights are driven by the fluid brightness — no fluid, no highlights.
FluidBackground composition
FluidBackground is a page or screen wrapper, not a local card widget.
Its fluid canvas is fixed to the viewport, and its slotted content sits
above the canvas with pointer-events: none so window-level pointer
input keeps feeding the simulation.
Keep elements inside the slot when they should carve holes in the fluid
via exclude. Add pointer-events: auto back to interactive children
such as nav bars, cards, and forms:
<script lang="ts">
import { FluidBackground } from 'svelte-fluid';
</script>
<FluidBackground class="fluid-screen" exclude=".site-nav, .panel" splatOnHover>
<nav class="site-nav">...</nav>
<main class="hero-copy">...</main>
<section class="panel">...</section>
</FluidBackground>
<style>
:global(.fluid-screen) {
min-height: 100vh;
isolation: isolate;
}
.site-nav,
.panel {
position: relative;
z-index: 2;
pointer-events: auto;
}
.hero-copy {
position: relative;
z-index: 1;
}
</style>
For a local bounded canvas, use <Fluid /> with a parent size or fixed
width / height instead.
Resize behavior
On resize, the engine keeps its WebGL context, programs, dye, and velocity in
place. Aspect changes resample the persistent fields; same-aspect changes only
resize canvas-sized presentation resources. Rendering uses the native device
pixel ratio by default; pass maxPixelRatio={2} to cap physical pixels on DPR 3+
screens.
Multiple instances
Each engine owns its fields, framebuffers, masks, uniforms, and input state.
All live engines subscribe to one shared RAF scheduler (ADR 0080).
WebGL2 engines use their own contexts for the first eight live own-context
slots, then use the shared host; the tier stays with the canvas. WebGL1 and
requireHardwareAcceleration engines stay own-context (ADR 0093).
Browsers still cap simultaneous contexts, including third-party WebGL.
The Svelte component owns DOM lifecycle, sizing, visibility, and accessible
fallback; it delegates rendering to the engine rather than issuing GL commands.
For dense pages, pass lazy={true} on each instance. The component defers
engine creation until the container approaches the viewport (50px observer
margin) and tears it down when it leaves that region, bounding live resources:
<LavaLamp lazy />
<Plasma lazy />
Lazy re-entry can require shader recompilation; the 50px margin is a
lookahead, not a guarantee that startup completes before the canvas is visible.
Programmatic engine
Not using the Svelte component? The raw engine works standalone:
import { FluidEngine } from 'svelte-fluid';
const canvas = document.querySelector('canvas')!;
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
const engine = new FluidEngine({
canvas,
config: { curl: 20, bloom: false, seed: 42 }
});
// later…
engine.splat(0.5, 0.5, 100, 0, { r: 1, g: 0.5, b: 0 });
engine.randomSplats(8);
engine.setConfig({ curl: 5 });
engine.dispose();
The public API is GL-type-neutral: no root-exported signature mentions a
WebGL* DOM type, FBO, DoubleFBO, ExtInfo or ResolvedConfig (removed in
1.0; they were internal). isWebGLAvailable() takes
{ failIfMajorPerformanceCaveat?: boolean }.
Development
This project uses bun.
bun install
bun run dev # demo playground at http://localhost:5173
bun run check # svelte-check
bun run package # produces dist/ with svelte-package
bun run build # builds the SvelteKit demo site
Contributing
See CONTRIBUTING.md for local setup, architecture
rules, and common workflows. Please open an issue before starting large
changes.
Acknowledgments
Built on PavelDoGreat/WebGL-Fluid-Simulation,
Pavel Dobryakov's iconic 2017 fluid sim. Shaders reused unchanged; both MIT-licensed.
The reveal and distortion effects were inspired by
Ksenia Kondrashova's creative WebGL
demos built on the same fluid simulation.