Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/fluid-ink.jsonIt adds these packages for you:
clsxtailwind-mergeThe source lands in your components folder as
fluid-ink.tsx. It's yours: change anything.
Usage
import { FluidInk } from "@/components/fluid-ink"
export function Hero() {
return (
<FluidInk className="flex min-h-[32rem] flex-col items-center justify-center px-6 text-center">
<h1 className="text-5xl font-semibold tracking-tight">Stir the ink.</h1>
<p className="mt-3 text-muted-foreground">Move your pointer through the page.</p>
</FluidInk>
)
}Source
One file, 789 lines, nothing hidden. Read it before you install it.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/** What `captureRef` receives. */
export interface FluidInkHandle {
/**
* Renders the ink as it is right now again at `width` × `height` pixels on
* the background colour and resolves a PNG. Pause first (`paused`) to pick
* the moment. Rejects when the device can't draw it (no WebGL, or a size
* past its limit).
*/
capture(width: number, height: number): Promise<Blob>;
}
export interface FluidInkProps extends React.HTMLAttributes<HTMLDivElement> {
/** Two to five ink colours, cycled as you stir. Any CSS colour. Defaults to the theme: `--primary`, `--chart-2`, `--chart-4`. */
colors?: string[];
/** How much the ink curls into eddies, 0–2. */
swirl?: number;
/** How long ink lingers before it fades, 0.25–3. */
linger?: number;
/** Size of each stroke of ink, 0.5–2. */
brush?: number;
/** On dark grounds, bright ink glows like light. */
glow?: boolean;
/** When nobody is stirring, an unseen hand keeps the ink moving. */
autoplay?: boolean;
/** The pointer (or a finger) stirs the ink, and a press bursts colour. */
interactive?: boolean;
/** A soft shade of the background behind the middle, so text over bright ink stays readable. */
scrim?: boolean;
/** Holds the ink still exactly as it is; the pointer leaves it alone until it plays again. */
paused?: boolean;
/** Gets a handle for saving the ink as an image, at any size: wallpapers, social cards, a 4K hero. */
captureRef?: React.Ref<FluidInkHandle>;
}
const THEME = ["var(--primary)", "var(--chart-2)", "var(--chart-4)"];
type Rgb = [number, number, number];
/** Resolves any CSS colour, theme variables included, to sRGB 0–1 via the element it is drawn in. */
function readColors(host: HTMLElement, colors: string[]): Rgb[] {
const probe = document.createElement("span");
probe.style.display = "none";
host.appendChild(probe);
const pixel = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const rgbs = colors.map((color): Rgb => {
probe.style.color = "";
probe.style.color = color;
if (!pixel) return [0.5, 0.5, 0.5];
pixel.clearRect(0, 0, 1, 1);
pixel.fillStyle = getComputedStyle(probe).color;
pixel.fillRect(0, 0, 1, 1);
const [r, g, b] = pixel.getImageData(0, 0, 1, 1).data;
return [r! / 255, g! / 255, b! / 255];
});
probe.remove();
return rgbs;
}
// A small seeded random, so the opening burst (and the still frame) is the same on every load.
function seeded(seed: number) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// The simulation: stable fluids on the GPU (advection, vorticity, a pressure solve), then shading and bloom.
const VERT = `precision highp float;
attribute vec2 aPosition; varying vec2 vUv, vL, vR, vT, vB; uniform vec2 texelSize;
void main(){ vUv=aPosition*.5+.5; vL=vUv-vec2(texelSize.x,0.); vR=vUv+vec2(texelSize.x,0.); vT=vUv+vec2(0.,texelSize.y); vB=vUv-vec2(0.,texelSize.y); gl_Position=vec4(aPosition,0.,1.); }`;
const frag = (body: string) => `precision highp float; precision highp sampler2D; varying vec2 vUv, vL, vR, vT, vB; ${body}`;
const SHADERS = {
clear: frag(`uniform sampler2D uTexture; uniform float value; void main(){ gl_FragColor=value*texture2D(uTexture,vUv); }`),
splat: frag(`uniform sampler2D uTarget; uniform float aspectRatio, radius; uniform vec3 color; uniform vec2 point;
void main(){ vec2 p=vUv-point; p.x*=aspectRatio; vec3 s=exp(-dot(p,p)/radius)*color; gl_FragColor=vec4(texture2D(uTarget,vUv).xyz+s,1.); }`),
advect: frag(`uniform sampler2D uVelocity, uSource; uniform vec2 texelSize; uniform float dt, dissipation;
void main(){ vec2 c=vUv-dt*texture2D(uVelocity,vUv).xy*texelSize; gl_FragColor=texture2D(uSource,c)/(1.+dissipation*dt); }`),
divergence: frag(`uniform sampler2D uVelocity;
void main(){ float L=texture2D(uVelocity,vL).x, R=texture2D(uVelocity,vR).x, T=texture2D(uVelocity,vT).y, B=texture2D(uVelocity,vB).y; vec2 C=texture2D(uVelocity,vUv).xy;
if(vL.x<0.)L=-C.x; if(vR.x>1.)R=-C.x; if(vT.y>1.)T=-C.y; if(vB.y<0.)B=-C.y; gl_FragColor=vec4(.5*(R-L+T-B),0.,0.,1.); }`),
curl: frag(`uniform sampler2D uVelocity;
void main(){ float L=texture2D(uVelocity,vL).y, R=texture2D(uVelocity,vR).y, T=texture2D(uVelocity,vT).x, B=texture2D(uVelocity,vB).x; gl_FragColor=vec4(.5*(R-L-T+B),0.,0.,1.); }`),
vorticity: frag(`uniform sampler2D uVelocity, uCurl; uniform float curl, dt;
void main(){ float L=texture2D(uCurl,vL).x, R=texture2D(uCurl,vR).x, T=texture2D(uCurl,vT).x, B=texture2D(uCurl,vB).x, C=texture2D(uCurl,vUv).x;
vec2 f=.5*vec2(abs(T)-abs(B),abs(R)-abs(L)); f/=length(f)+.0001; f*=curl*C; f.y*=-1.;
vec2 v=texture2D(uVelocity,vUv).xy+f*dt; gl_FragColor=vec4(clamp(v,-1000.,1000.),0.,1.); }`),
pressure: frag(`uniform sampler2D uPressure, uDivergence;
void main(){ float L=texture2D(uPressure,vL).x, R=texture2D(uPressure,vR).x, T=texture2D(uPressure,vT).x, B=texture2D(uPressure,vB).x;
gl_FragColor=vec4((L+R+B+T-texture2D(uDivergence,vUv).x)*.25,0.,0.,1.); }`),
gradient: frag(`uniform sampler2D uPressure, uVelocity;
void main(){ float L=texture2D(uPressure,vL).x, R=texture2D(uPressure,vR).x, T=texture2D(uPressure,vT).x, B=texture2D(uPressure,vB).x;
gl_FragColor=vec4(texture2D(uVelocity,vUv).xy-vec2(R-L,T-B),0.,1.); }`),
prefilter: frag(`uniform sampler2D uTexture; uniform vec3 curve; uniform float threshold;
void main(){ vec3 c=texture2D(uTexture,vUv).rgb; float br=max(c.r,max(c.g,c.b)); float rq=clamp(br-curve.x,0.,curve.y); rq=curve.z*rq*rq;
c*=max(rq,br-threshold)/max(br,.0001); gl_FragColor=vec4(c,0.); }`),
blur: frag(`uniform sampler2D uTexture;
void main(){ gl_FragColor=(texture2D(uTexture,vL)+texture2D(uTexture,vR)+texture2D(uTexture,vT)+texture2D(uTexture,vB))*.25; }`),
bloomFinal: frag(`uniform sampler2D uTexture; uniform float intensity;
void main(){ gl_FragColor=(texture2D(uTexture,vL)+texture2D(uTexture,vR)+texture2D(uTexture,vT)+texture2D(uTexture,vB))*.25*intensity; }`),
display: frag(`uniform sampler2D uTexture, uBloom; uniform vec2 texelSize; uniform float bloomOn, lightMode;
void main(){
vec3 c=texture2D(uTexture,vUv).rgb;
float dx=length(texture2D(uTexture,vR).rgb)-length(texture2D(uTexture,vL).rgb);
float dy=length(texture2D(uTexture,vT).rgb)-length(texture2D(uTexture,vB).rgb);
vec3 n=normalize(vec3(dx,dy,length(texelSize)));
c*=clamp(dot(n,vec3(0.,0.,1.))+.7,.7,1.);
c+=pow(max(texture2D(uBloom,vUv).rgb*bloomOn,0.),vec3(1./2.2));
// Ink never reaches pure white (or fully opaque stain): brightness eases toward a ceiling, so text above stays readable.
float l=max(c.r,max(c.g,c.b));
if(l>.55) c*=(.55+.27*(1.-exp(-(l-.55)/.27)))/l;
float a=clamp(max(c.r,max(c.g,c.b)),0.,1.);
// On a light ground ink should stain the paper rather than glow: deepen it and composite normally.
gl_FragColor=vec4(c/max(a,.0001)*mix(1.,.72,lightMode)*a,a);
}`),
};
type Target = { fbo: WebGLFramebuffer; w: number; h: number; tx: number; ty: number; bind: (unit: number) => number };
type Double = { read: Target; write: Target; swap: () => void; tx: number; ty: number };
/**
* Ink that flows like a real fluid behind your content: stir it with the
* pointer, press to burst colour, and watch it curl into eddies and fade.
*
* - A genuine fluid simulation on the GPU (WebGL, no libraries): the eddies
* are physics, not a loop. On dark grounds bright ink blooms like light;
* on light grounds it stains the page like marbled paper.
* - Colours come from the theme by default, so it matches any brand.
* - When nobody is stirring, an unseen hand keeps it alive (turn off with
* `autoplay={false}`). It pauses off-screen and in hidden tabs.
* - It watches its own frame rate: on a device that can't keep up it lowers
* its resolution, then settles into a still image of the theme colours.
* No WebGL at all gives the same still image, never an empty box.
* - With `prefers-reduced-motion` it paints one still frame and ignores the
* pointer. Decorative: hidden from assistive technology; your content sits
* above it, and presses on links and buttons stay theirs.
*/
export function FluidInk({
colors = [],
swirl = 1,
linger = 1,
brush = 1,
glow = true,
autoplay = true,
interactive = true,
scrim = true,
paused = false,
captureRef,
className,
style,
children,
...props
}: FluidInkProps) {
const ref = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const list = colors.length >= 2 ? colors.slice(0, 5) : THEME;
const key = list.join("|");
const settings = React.useRef({ swirl, linger, brush, glow, autoplay, interactive, paused });
// The simulation reads the latest props without restarting.
React.useEffect(() => {
settings.current = { swirl, linger, brush, glow, autoplay, interactive, paused };
});
// Set by the running simulation: wake it when it plays again, and render a still at a size.
const wake = React.useRef<(() => void) | null>(null);
const grab = React.useRef<FluidInkHandle["capture"] | null>(null);
React.useEffect(() => {
if (!paused) wake.current?.();
}, [paused]);
React.useImperativeHandle(
captureRef,
() => ({ capture: (width, height) => grab.current?.(width, height) ?? Promise.reject(new Error("The ink can't be drawn on this device.")) }),
[],
);
React.useEffect(() => {
const host = ref.current;
const canvas = canvasRef.current;
if (!host || !canvas) return;
const palette = key.split("|");
const still = window.matchMedia("(prefers-reduced-motion: reduce)");
const poster = () => {
host.dataset.fluid = "poster";
};
const options = { alpha: true, depth: false, stencil: false, antialias: false, premultipliedAlpha: true, preserveDrawingBuffer: false };
const gl2 = canvas.getContext("webgl2", options) as WebGL2RenderingContext | null;
const gl = (gl2 ?? canvas.getContext("webgl", options)) as WebGLRenderingContext | null;
if (!gl || gl.isContextLost()) return poster();
// A software renderer (no GPU, or one the browser blocked) can run the
// simulation, but only by eating the main thread: show the still instead,
// and paint the ink only for a capture someone asks for.
const info = gl.getExtension("WEBGL_debug_renderer_info");
const software = /swiftshader|llvmpipe|softpipe|software|basic render/i.test(
String(gl.getParameter(info ? info.UNMASKED_RENDERER_WEBGL : gl.RENDERER)),
);
let HALF: number | undefined;
if (gl2) {
gl2.getExtension("EXT_color_buffer_float");
HALF = gl2.HALF_FLOAT;
} else if (gl.getExtension("OES_texture_half_float_linear")) {
HALF = gl.getExtension("OES_texture_half_float")?.HALF_FLOAT_OES;
}
if (!HALF) return poster();
const half = HALF;
const renderable = (internal: number, format: number) => {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texImage2D(gl.TEXTURE_2D, 0, internal, 4, 4, 0, format, half, null);
const f = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, f);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
gl.deleteFramebuffer(f);
gl.deleteTexture(t);
return ok ? { internal, format } : null;
};
const RGBA = gl2 ? renderable(gl2.RGBA16F, gl.RGBA) : renderable(gl.RGBA, gl.RGBA);
if (!RGBA) return poster();
const RG = (gl2 && renderable(gl2.RG16F, gl2.RG)) || RGBA;
const R = (gl2 && renderable(gl2.R16F, gl2.RED)) || RG;
// Programs.
const compile = (type: number, src: string) => {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;
};
const vert = compile(gl.VERTEX_SHADER, VERT);
type Program = { use: () => Record<string, WebGLUniformLocation | null> };
const programs: Partial<Record<keyof typeof SHADERS, Program>> = {};
const linked: WebGLProgram[] = [];
for (const [name, src] of Object.entries(SHADERS) as [keyof typeof SHADERS, string][]) {
const f = compile(gl.FRAGMENT_SHADER, src);
const p = gl.createProgram();
if (!vert || !f || !p) return poster();
gl.attachShader(p, vert);
gl.attachShader(p, f);
gl.bindAttribLocation(p, 0, "aPosition");
gl.linkProgram(p);
linked.push(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) return poster();
const u: Record<string, WebGLUniformLocation | null> = {};
for (let i = 0; i < gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); i++) {
const info = gl.getActiveUniform(p, i);
if (info) u[info.name] = gl.getUniformLocation(p, info.name);
}
programs[name] = { use: () => (gl.useProgram(p), u) };
}
const P = programs as Record<keyof typeof SHADERS, Program>;
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);
gl.clearColor(0, 0, 0, 0);
const blit = (target: Target | null, clear = false) => {
gl.viewport(0, 0, target ? target.w : gl.drawingBufferWidth, target ? target.h : gl.drawingBufferHeight);
gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.fbo : null);
if (clear) gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
};
const made: { tex: WebGLTexture | null; fbo: WebGLFramebuffer | null }[] = [];
const target = (w: number, h: number, f: { internal: number; format: number }, filter: number): Target => {
gl.activeTexture(gl.TEXTURE0);
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, f.internal, w, h, 0, f.format, half, null);
const fbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.viewport(0, 0, w, h);
gl.clear(gl.COLOR_BUFFER_BIT);
made.push({ tex, fbo });
return {
fbo,
w,
h,
tx: 1 / w,
ty: 1 / h,
bind: (unit) => {
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, tex);
return unit;
},
};
};
const double = (w: number, h: number, f: { internal: number; format: number }, filter: number): Double => {
let a = target(w, h, f, filter);
let b = target(w, h, f, filter);
return {
get read() {
return a;
},
get write() {
return b;
},
swap: () => ([a, b] = [b, a]),
tx: 1 / w,
ty: 1 / h,
};
};
const size = (r: number) => {
const W = gl.drawingBufferWidth;
const H = gl.drawingBufferHeight;
const ratio = Math.max(W, H) / Math.max(Math.min(W, H), 1);
const long = Math.round(r * ratio);
return W > H ? { w: long, h: r } : { w: r, h: long };
};
// Phones start at half the ink resolution; anything that still can't keep up steps down from there.
// The ink is soft, so the canvas runs below the screen's full sharpness: 1.5x, then 1x.
let sharpness = 1.5;
let dyeRes = window.matchMedia("(max-width: 700px)").matches ? 512 : 1024;
let dye: Double, velocity: Double, pressure: Double, divergence: Target, curl: Target, bloom: Target, blooms: Target[];
const build = () => {
for (const m of made.splice(0)) {
gl.deleteTexture(m.tex);
gl.deleteFramebuffer(m.fbo);
}
const s = size(128);
const d = size(dyeRes);
const b = size(256);
dye = double(d.w, d.h, RGBA, gl.LINEAR);
velocity = double(s.w, s.h, RG, gl.LINEAR);
divergence = target(s.w, s.h, R, gl.NEAREST);
curl = target(s.w, s.h, R, gl.NEAREST);
pressure = double(s.w, s.h, R, gl.NEAREST);
bloom = target(b.w, b.h, RGBA, gl.LINEAR);
blooms = [];
for (let i = 0, w = b.w, h = b.h; i < 8; i++) {
w >>= 1;
h >>= 1;
if (w < 2 || h < 2) break;
blooms.push(target(w, h, RGBA, gl.LINEAR));
}
};
const resize = () => {
const rect = host.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, sharpness);
const w = Math.max(Math.round(rect.width * ratio), 1);
const h = Math.max(Math.round(rect.height * ratio), 1);
if (canvas.width === w && canvas.height === h) return false;
canvas.width = w;
canvas.height = h;
return true;
};
const aspect = () => canvas.width / Math.max(canvas.height, 1);
const splat = (x: number, y: number, dx: number, dy: number, color: Rgb) => {
const u = P.splat.use();
const r = (0.22 / 100) * settings.current.brush;
gl.uniform1f(u.aspectRatio!, aspect());
gl.uniform2f(u.point!, x, y);
gl.uniform1f(u.radius!, aspect() > 1 ? r * aspect() : r);
gl.uniform1i(u.uTarget!, velocity.read.bind(0));
gl.uniform3f(u.color!, dx, dy, 0);
blit(velocity.write);
velocity.swap();
gl.uniform1i(u.uTarget!, dye.read.bind(0));
gl.uniform3f(u.color!, ...color);
blit(dye.write);
dye.swap();
};
const step = (dt: number) => {
gl.disable(gl.BLEND);
let u = P.curl.use();
gl.uniform2f(u.texelSize!, velocity.tx, velocity.ty);
gl.uniform1i(u.uVelocity!, velocity.read.bind(0));
blit(curl);
u = P.vorticity.use();
gl.uniform2f(u.texelSize!, velocity.tx, velocity.ty);
gl.uniform1i(u.uVelocity!, velocity.read.bind(0));
gl.uniform1i(u.uCurl!, curl.bind(1));
gl.uniform1f(u.curl!, 28 * settings.current.swirl);
gl.uniform1f(u.dt!, dt);
blit(velocity.write);
velocity.swap();
u = P.divergence.use();
gl.uniform2f(u.texelSize!, velocity.tx, velocity.ty);
gl.uniform1i(u.uVelocity!, velocity.read.bind(0));
blit(divergence);
u = P.clear.use();
gl.uniform1i(u.uTexture!, pressure.read.bind(0));
gl.uniform1f(u.value!, 0.8);
blit(pressure.write);
pressure.swap();
u = P.pressure.use();
gl.uniform2f(u.texelSize!, velocity.tx, velocity.ty);
gl.uniform1i(u.uDivergence!, divergence.bind(0));
for (let i = 0; i < 20; i++) {
gl.uniform1i(u.uPressure!, pressure.read.bind(1));
blit(pressure.write);
pressure.swap();
}
u = P.gradient.use();
gl.uniform2f(u.texelSize!, velocity.tx, velocity.ty);
gl.uniform1i(u.uPressure!, pressure.read.bind(0));
gl.uniform1i(u.uVelocity!, velocity.read.bind(1));
blit(velocity.write);
velocity.swap();
u = P.advect.use();
gl.uniform2f(u.texelSize!, velocity.tx, velocity.ty);
gl.uniform1f(u.dt!, dt);
const v = velocity.read.bind(0);
gl.uniform1i(u.uVelocity!, v);
gl.uniform1i(u.uSource!, v);
gl.uniform1f(u.dissipation!, 0.25);
blit(velocity.write);
velocity.swap();
gl.uniform1i(u.uVelocity!, velocity.read.bind(0));
gl.uniform1i(u.uSource!, dye.read.bind(1));
gl.uniform1f(u.dissipation!, 0.9 / Math.max(settings.current.linger, 0.1));
blit(dye.write);
dye.swap();
};
const applyBloom = () => {
const threshold = 0.55;
const knee = threshold * 0.7 + 0.0001;
let u = P.prefilter.use();
gl.uniform3f(u.curve!, threshold - knee, knee * 2, 0.25 / knee);
gl.uniform1f(u.threshold!, threshold);
gl.uniform1i(u.uTexture!, dye.read.bind(0));
blit(bloom);
u = P.blur.use();
let last = bloom;
for (const dest of blooms) {
gl.uniform2f(u.texelSize!, last.tx, last.ty);
gl.uniform1i(u.uTexture!, last.bind(0));
blit(dest);
last = dest;
}
gl.blendFunc(gl.ONE, gl.ONE);
gl.enable(gl.BLEND);
for (let i = blooms.length - 2; i >= 0; i--) {
gl.uniform2f(u.texelSize!, last.tx, last.ty);
gl.uniform1i(u.uTexture!, last.bind(0));
blit(blooms[i]!);
last = blooms[i]!;
}
gl.disable(gl.BLEND);
u = P.bloomFinal.use();
gl.uniform2f(u.texelSize!, last.tx, last.ty);
gl.uniform1i(u.uTexture!, last.bind(0));
gl.uniform1f(u.intensity!, 0.75);
blit(bloom);
};
// The ground decides how ink behaves: glowing light on dark, staining ink on light.
let inks: Rgb[] = [];
let light = false;
const read = () => {
const [ground, ...rest] = readColors(host, ["var(--background)", ...palette]);
inks = rest;
light = 0.2126 * ground![0] + 0.7152 * ground![1] + 0.0722 * ground![2] > 0.5;
};
read();
const signature = () =>
["--background", ...palette.map((c) => c.match(/var\((--[\w-]+)/)?.[1] ?? "--ux-none")].map((v) => getComputedStyle(host).getPropertyValue(v)).join();
let seenColors = signature();
const render = () => {
const bloomOn = settings.current.glow && !light;
if (bloomOn) applyBloom();
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.enable(gl.BLEND);
const u = P.display.use();
gl.uniform2f(u.texelSize!, 1 / gl.drawingBufferWidth, 1 / gl.drawingBufferHeight);
gl.uniform1i(u.uTexture!, dye.read.bind(0));
gl.uniform1i(u.uBloom!, bloom.bind(1));
gl.uniform1f(u.bloomOn!, bloomOn ? 1 : 0);
gl.uniform1f(u.lightMode!, light ? 1 : 0);
blit(null, true);
};
// Ink colours cycle slowly through the palette as you stir.
let hue = 0;
const ink = (strength: number): Rgb => {
hue += 0.012;
const n = inks.length;
const t = hue % n;
const a = inks[Math.floor(t)]!;
const b = inks[(Math.floor(t) + 1) % n]!;
const f = t - Math.floor(t);
const s = light ? strength * 1.6 : strength;
return [0, 1, 2].map((i) => (a[i]! + (b[i]! - a[i]!) * f) * s) as Rgb;
};
const random = seeded(7);
const burst = (count: number) => {
for (let i = 0; i < count; i++) {
splat(0.15 + random() * 0.7, 0.15 + random() * 0.7, 1000 * (random() - 0.5), 1000 * (random() - 0.5), ink(0.5));
}
};
const pointer = { x: 0.5, y: 0.5, dx: 0, dy: 0, moved: false, last: -1e9 };
const at = (event: PointerEvent) => {
const rect = host.getBoundingClientRect();
return [(event.clientX - rect.left) / rect.width, 1 - (event.clientY - rect.top) / rect.height] as const;
};
const move = (event: PointerEvent) => {
if (!settings.current.interactive || still.matches) return;
const [x, y] = at(event);
let dx = x - pointer.x;
let dy = y - pointer.y;
if (aspect() < 1) dx *= aspect();
if (aspect() > 1) dy /= aspect();
// A pointer arriving from outside starts where it is instead of streaking in.
const fresh = performance.now() - pointer.last > 200;
pointer.x = x;
pointer.y = y;
pointer.dx = fresh ? 0 : dx * 6000;
pointer.dy = fresh ? 0 : dy * 6000;
pointer.moved = !fresh;
pointer.last = performance.now();
loop();
};
const press = (event: PointerEvent) => {
if (!settings.current.interactive || still.matches) return;
if ((event.target as Element).closest("a, button, input, textarea, select, label, [role='button']")) return;
const [x, y] = at(event);
pointer.x = x;
pointer.y = y;
pointer.last = performance.now();
for (let i = 0; i < 3; i++) splat(x, y, 900 * (Math.random() - 0.5), 900 * (Math.random() - 0.5), ink(0.45));
loop();
};
// When nobody is stirring, an unseen hand drifts through so a hero is never dead.
let ghost = 0;
const drift = (now: number, dt: number) => {
if (!settings.current.autoplay || now - pointer.last < 2500) return;
const path = (g: number) => [0.5 + 0.34 * Math.sin(g * 0.9) * Math.cos(g * 0.31), 0.5 + 0.28 * Math.sin(g * 1.3 + 1.1)] as const;
const [px, py] = path(ghost);
ghost += dt * 0.6;
const [x, y] = path(ghost);
splat(x, y, (x - px) * 5400, (y - py) * 5400, ink(0.12));
};
let frame = 0;
let last = 0;
let visible = true;
let frames = 0;
let clock = 0;
let slow = 0;
let started = 0;
let checked = 0;
const tick = (now: number) => {
frame = 0;
const dt = Math.min((now - (last || now)) / 1000, 1 / 30);
last = now;
if (!started) started = now;
if (resize()) {
build();
started = now;
}
if (now - checked > 1000) {
checked = now;
const next = signature();
if (next !== seenColors) {
seenColors = next;
read();
}
}
if (settings.current.paused) {
// Held: the ink stays exactly as it is until it plays again.
pointer.moved = false;
render();
last = 0;
return;
}
if (pointer.moved) {
splat(pointer.x, pointer.y, pointer.dx, pointer.dy, ink(0.16));
pointer.moved = false;
}
drift(now, dt);
step(dt);
render();
// Watch the frame rate after a warm-up. Below 28 fps (30 is a phone's battery saver, and still smooth
// ink) for two seconds it steps down: sharpness, then ink resolution. Only a device that stays under
// 18 fps at the floor gets the still image.
frames++;
if (!clock) clock = now;
if (now - clock >= 1000) {
const fps = (frames * 1000) / (now - clock);
frames = 0;
clock = now;
const floor = sharpness <= 1 && dyeRes <= 256;
if (now - started > 2000 && fps < (floor ? 18 : 28)) {
if (++slow >= (floor ? 3 : 2)) {
slow = 0;
started = now;
if (floor) return poster();
if (sharpness > 1) {
sharpness = 1;
resize();
} else dyeRes >>= 1;
build();
burst(4);
}
} else slow = 0;
}
loop();
};
// With autoplay off, the simulation sleeps a few seconds after the last stir and wakes on the next one.
const born = performance.now();
const idle = () => !settings.current.autoplay && performance.now() > Math.max(pointer.last, born) + 6000;
function loop() {
if (frame || host!.dataset.fluid === "poster" || !visible || document.hidden || still.matches) return;
if (idle()) {
last = 0;
started = 0;
clock = 0;
frames = 0;
return;
}
frame = requestAnimationFrame(tick);
}
const stop = () => {
cancelAnimationFrame(frame);
frame = 0;
started = 0;
slow = 0;
last = 0;
clock = 0;
frames = 0;
};
// Reduced motion: let an opening burst settle into a still painting.
const paintStill = () => {
build();
burst(5);
for (let i = 0; i < 90; i++) step(1 / 60);
render();
};
const sizes = new ResizeObserver(() => {
if (resize()) {
build();
burst(3);
if (still.matches) paintStill();
}
});
sizes.observe(host);
const seen = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true;
if (visible) loop();
else stop();
});
seen.observe(host);
const onVisibility = () => (document.hidden ? stop() : loop());
document.addEventListener("visibilitychange", onVisibility);
const onMotion = () => (still.matches ? (stop(), paintStill()) : loop());
still.addEventListener("change", onMotion);
const scheme = new MutationObserver(() => {
read();
if (still.matches) render();
});
scheme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
const lost = (event: Event) => {
event.preventDefault();
stop();
poster();
};
canvas.addEventListener("webglcontextlost", lost);
host.addEventListener("pointermove", move);
host.addEventListener("pointerdown", press);
// A still at any size: the ink as it is, drawn again into a canvas that big, laid on the background, then put back.
let painted = false;
grab.current = (w, h) =>
new Promise<Blob>((resolve, reject) => {
const max = Math.min(gl.getParameter(gl.MAX_TEXTURE_SIZE), ...(gl.getParameter(gl.MAX_VIEWPORT_DIMS) as Int32Array));
if (gl.isContextLost()) return reject(new Error("The ink can't be drawn on this device."));
if (!(w >= 1 && h >= 1) || w > max || h > max) return reject(new RangeError(`This device draws images up to ${max} pixels across.`));
// Measure again first: without a live loop the size taken at mount (maybe mid-layout) is all it knows.
if (resize()) painted = false;
// Nothing has been painted yet (a software renderer shows the still): half a second of the unseen hand
// stirring an opening burst, as the live ink would look.
if (!painted) {
build();
burst(5);
for (let i = 0; i < 30; i++) {
drift(1e12, 1 / 60);
step(1 / 60);
}
painted = true;
}
const kept = { w: canvas.width, h: canvas.height };
canvas.width = Math.round(w);
canvas.height = Math.round(h);
const fits = gl.drawingBufferWidth === canvas.width && gl.drawingBufferHeight === canvas.height;
const flat = document.createElement("canvas");
flat.width = canvas.width;
flat.height = canvas.height;
const pen = flat.getContext("2d");
if (fits && pen) {
render();
// Copy in the same task as the draw: the picture is still in the drawing buffer.
pen.fillStyle = getComputedStyle(host).backgroundColor;
pen.fillRect(0, 0, flat.width, flat.height);
pen.drawImage(canvas, 0, 0);
}
canvas.width = kept.w;
canvas.height = kept.h;
if (host.dataset.fluid === "live") render();
if (!fits || !pen) return reject(new RangeError(`This device ran out of graphics memory at ${w} × ${h}.`));
flat.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("The browser couldn't encode the image."))), "image/png");
});
wake.current = loop;
resize();
if (software) poster();
else {
painted = true;
if (still.matches) paintStill();
else {
build();
burst(5);
loop();
}
host.dataset.fluid = "live";
}
return () => {
stop();
wake.current = null;
grab.current = null;
sizes.disconnect();
seen.disconnect();
scheme.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
still.removeEventListener("change", onMotion);
canvas.removeEventListener("webglcontextlost", lost);
host.removeEventListener("pointermove", move);
host.removeEventListener("pointerdown", press);
// Free the GPU memory now. The context itself stays usable: a remount of
// this canvas (React does one in development) gets the same context back.
for (const m of made.splice(0)) {
gl.deleteTexture(m.tex);
gl.deleteFramebuffer(m.fbo);
}
for (const p of linked) gl.deleteProgram(p);
delete host.dataset.fluid;
};
}, [key]);
return (
<div
ref={ref}
className={cn("group/fluid relative isolate overflow-hidden bg-background text-foreground", className)}
style={{ "--fluid-1": list[0], "--fluid-2": list[1], "--fluid-3": list[2] ?? list[0], ...style } as React.CSSProperties}
{...props}
>
{/* The still image: shown when there is no WebGL or the device can't keep up. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 hidden group-data-[fluid=poster]/fluid:block"
style={{
background:
"radial-gradient(42% 55% at 28% 38%, color-mix(in oklab, var(--fluid-1) 45%, transparent), transparent 70%), radial-gradient(38% 50% at 72% 58%, color-mix(in oklab, var(--fluid-2) 40%, transparent), transparent 70%), radial-gradient(30% 40% at 50% 80%, color-mix(in oklab, var(--fluid-3) 35%, transparent), transparent 70%)",
}}
/>
<canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 size-full group-data-[fluid=poster]/fluid:hidden" />
{scrim && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10"
style={{ background: "radial-gradient(60% 50% at 50% 50%, color-mix(in oklab, var(--background) 62%, transparent), transparent)" }}
/>
)}
{children}
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| colors | string[] | [] (theme: --primary, --chart-2, --chart-4) | Two to five ink colours, cycled as you stir. Any CSS colour. |
| swirl | number | 1 | How much the ink curls into eddies, from 0 (smooth) to 2. |
| linger | number | 1 | How long ink stays before it fades, from 0.25 to 3. |
| brush | number | 1 | Size of each stroke of ink, from 0.5 to 2. |
| glow | boolean | true | On dark grounds, bright ink blooms like light. Light grounds never glow: ink stains them like marbled paper. |
| autoplay | boolean | true | When nobody is stirring, an unseen hand keeps the ink moving. Off, the simulation sleeps a few seconds after the last stir. |
| interactive | boolean | true | The pointer or a finger stirs the ink, and a press bursts colour. Presses on links and buttons stay theirs. |
| scrim | boolean | true | A soft shade of the background behind the middle, so text over bright ink stays readable. Turn it off when nothing sits on top. |
| paused | boolean | false | Holds the ink still exactly as it is: pause when it looks right, then capture it. The pointer leaves it alone until it plays again. |
| captureRef | Ref<FluidInkHandle> | — | Gets { capture(width, height) }, which draws the ink as it is right now again at that size, on your background colour, and resolves a PNG Blob: a 4K hero, a wallpaper, a social card. It works on a software renderer too, and rejects with a plain message when the device can't draw that size. |
| ...props | HTMLAttributes<HTMLDivElement> | — | Any div attribute. Your className is merged last, so it wins; size the background with it. |
Accessibility
- The canvas and its still image are decorative and hidden from assistive technology; your content is ordinary markup above them.
- Bright ink eases off before pure white, and a soft shade sits behind the middle, so text above keeps its contrast.
- With prefers-reduced-motion it paints one still frame and ignores the pointer.
- It pauses off-screen and in hidden tabs, and frees its GPU memory when removed.
- Without WebGL, or on a device that can't hold a smooth frame rate even at low resolution, it shows a still image in your theme colours.
Changelog
- v1.1.0Latest
New: `paused` holds the motion still, and `captureRef` saves it as a PNG at any size.