WebGL fluid simulation as a Svelte 5 component library.
Other WebGL fluid packages are vanilla JS wrappers around the same upstream simulation. svelte-fluid is purpose-built for Svelte 5:
<Fluid /> with 70+ typed props, live reactive updates, and full cleanup on unmountseed reproduces the same splat pattern across resizes<LavaLamp />, <Aurora />, <CircularFluid />, <SvgPathFluid />, etc.splat() and randomSplats() via bind:thisnpm install svelte-fluid
# or
pnpm add svelte-fluid
# or
bun add svelte-fluid
Requires Svelte ≥ 5.
<script lang="ts">
import { Fluid } from 'svelte-fluid';
</script>
<div style="width:100%; height:100vh">
<Fluid />
</div>
That's the entire setup. The canvas fills its parent and tracks parent
size via ResizeObserver.
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>
<Fluid width={400} height={300} />
<Fluid
curl={20}
splatRadius={0.5}
bloom={false}
shading
densityDissipation={0.4}
initialSplatCount={12}
seed={42}
/>
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} />
lazy={true}
strongly recommended on dense pagesOES_texture_float_linear is unavailable,
the engine drops shading, bloom, sunrays and clamps dyeResolution ≤ 512 —
see FluidEngine.initContext()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. |
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 |
curl |
number |
30 |
vorticity confinement; hot |
splatRadius |
number |
0.25 |
hot |
splatForce |
number |
6000 |
hot |
shading |
boolean |
true |
shader recompile |
colorful |
boolean |
true |
hot; rotate pointer/touch splat colors over time |
colorUpdateSpeed |
number |
10 |
hot; pointer/touch color rotation rate |
paused |
boolean |
false |
hot |
backColor |
{r,g,b} |
{0,0,0} |
0–255 RGB; hot |
transparent |
boolean |
false |
hot |
bloom |
boolean |
true |
shader recompile |
bloomIterations |
number |
8 |
rebuilds FBOs |
bloomResolution |
number |
256 |
rebuilds FBOs |
bloomIntensity |
number |
0.8 |
hot |
bloomThreshold |
number |
0.6 |
hot |
bloomSoftKnee |
number |
0.7 |
hot |
sunrays |
boolean |
true |
shader recompile |
sunraysResolution |
number |
196 |
rebuilds FBOs |
sunraysWeight |
number |
1 |
hot |
initialSplatCount |
number |
— | exact count for the first frame |
initialSplatCountMin |
number |
5 |
min of random range |
initialSplatCountMax |
number |
25 |
max of random range |
autoSplatRate |
number |
0 |
automatic burst rate in splats/sec; 0 = disabled (Bucket A) |
autoSplatCount |
number |
1 |
number of splats emitted each burst (Bucket A) |
autoSplatColor |
{r,g,b} |
null |
fixed color for automatic splats; null = fresh random color per splat (Bucket A) |
autoSplatVelocityX |
number |
0 |
x velocity for automatic splats; ignored when autoSplatSwirl is nonzero (Bucket A) |
autoSplatVelocityY |
number |
0 |
y velocity for automatic splats; negative moves downward in DOM space (Bucket A) |
autoSplatCenterY |
number |
0.5 |
vertical center of the spawn band: 0 = bottom, 0.5 = center, 1 = top (Bucket A) |
autoSplatEvenX |
boolean |
false |
within each burst, use equal x positions instead of random x positions |
autoSplatSwirl |
number |
0 |
orbital velocity around the container/canvas center; positive = CCW |
autoSplatBandHeight |
number |
0.1 |
height of the spawn band; 0 = single line, 0.1 = ±5%, 2.0 = full canvas |
pointerInput |
boolean |
true |
hot; toggles canvas + window listeners |
splatOnHover |
boolean |
false |
hot; splat on mousemove without click |
containerShape |
ContainerShape |
null |
confine fluid to a shape; see Container shapes |
glass |
boolean |
false |
glass post-processing; requires containerShape; see Glass effect |
glassThickness |
number |
0.04 |
glass wall width in UV units (rim model only) |
glassRefraction |
number |
0.4 |
refraction strength 0–1; mapped to IOR 1.0–2.0 |
glassReflectivity |
number |
0.12 |
specular intensity (Fresnel F0) 0–1 |
glassChromatic |
number |
0.15 |
chromatic aberration strength 0–1 |
presetSplats |
PresetSplat[] |
— | construct-only; declarative initial scene (see Presets) |
lazy |
boolean |
false |
construct-only; defer engine creation until container enters viewport |
autoPause |
boolean |
true |
pause when not visible (IntersectionObserver + visibilitychange) |
The component also forwards any standard <canvas> attributes
(class, style, aria-label, …) onto the underlying canvas via
...rest.
Ten opinionated wrapper components ship alongside <Fluid />. Each one
hard-codes a physics + visual configuration and (for most of them) a
hand-crafted set of opening splats so you can drop them in without any
tuning:
| Component | Look |
|---|---|
<LavaLamp /> |
Warm blobs in a glass vessel with rim refraction |
<Plasma /> |
Full-spectrum jets converging into a bright plasma 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 |
<ToroidalTempest /> |
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 |
<script lang="ts">
import { LavaLamp, Plasma, ToroidalTempest } from 'svelte-fluid';
</script>
<div style="height: 100vh">
<LavaLamp />
</div>
<div style="display:grid; grid-template-columns:repeat(2, 1fr); gap:16px">
<Plasma />
<ToroidalTempest />
</div>
Each preset forwards a small common set of props: width, height,
class, style, seed, lazy, and aria-label. Some shape presets
also expose small shape-specific knobs such as splatOnHover or 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.
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)
};
</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. To paint a new scene, change the seed (which forces
a teardown/rebuild) or call handle.splat() imperatively.
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.
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".
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).
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 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.
A ResizeObserver watches the wrapper container. Whenever the CSS
dimensions change, the engine is fully torn down and reinstantiated with
the same seed, so the initial splat pattern is identical. If you don't
provide a seed, the component generates one once at mount and reuses
it across resizes.
Each <Fluid /> owns its own WebGL context, framebuffers, RAF loop,
listeners, and pointer state. Browsers cap simultaneous WebGL contexts
at 8–16 per tab, so plan accordingly for very dense layouts.
For pages with more than ~6 simultaneous instances, pass lazy={true}
on each one. The component will then defer engine creation until the
container enters the viewport (with a 200px lookahead) and tear it
down when it leaves, keeping the live context count bounded:
<LavaLamp lazy />
<Plasma lazy />
The cost is a one-time shader-recompile pause (~100–500ms) when an instance scrolls back into view. For demo / showcase pages this is hidden behind the user's scroll momentum and rarely noticed.
If you need raw control without the Svelte component:
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();
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
See CONTRIBUTING.md for local setup, architecture rules, and common workflows. Please open an issue before starting large changes.
This library is a derivative work of
PavelDoGreat/WebGL-Fluid-Simulation,
the original 2017 WebGL implementation by Pavel Dobryakov. The shader
sources are reused unchanged. Both the upstream project and this port
are MIT-licensed; see LICENSE for the full notices.
The reveal and distortion effects were inspired by Ksenia Kondrashova's creative WebGL demos built on the same fluid simulation.