Skip to content
Uxelith
Sign in
Freev1.1.0Backgrounds

Fluid Ink

Ink that flows like a real fluid behind your content: stir it with the pointer, press to burst colour, and watch it curl and fade. A WebGL fluid simulation in your theme colours, with no libraries.

pnpm dlx shadcn add …/r/fluid-ink.json
Licence
MIT
Packages
2
Updated
29 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/fluid-ink.json
  2. It adds these packages for you:

    clsxtailwind-merge
  3. The source lands in your components folder as fluid-ink.tsx. It's yours: change anything.

Usage

example.tsx
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.

fluid-ink.tsx
"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

PropTypeDefaultDescription
colorsstring[][] (theme: --primary, --chart-2, --chart-4)Two to five ink colours, cycled as you stir. Any CSS colour.
swirlnumber1How much the ink curls into eddies, from 0 (smooth) to 2.
lingernumber1How long ink stays before it fades, from 0.25 to 3.
brushnumber1Size of each stroke of ink, from 0.5 to 2.
glowbooleantrueOn dark grounds, bright ink blooms like light. Light grounds never glow: ink stains them like marbled paper.
autoplaybooleantrueWhen nobody is stirring, an unseen hand keeps the ink moving. Off, the simulation sleeps a few seconds after the last stir.
interactivebooleantrueThe pointer or a finger stirs the ink, and a press bursts colour. Presses on links and buttons stay theirs.
scrimbooleantrueA soft shade of the background behind the middle, so text over bright ink stays readable. Turn it off when nothing sits on top.
pausedbooleanfalseHolds the ink still exactly as it is: pause when it looks right, then capture it. The pointer leaves it alone until it plays again.
captureRefRef<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.
...propsHTMLAttributes<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

  1. v1.1.0Latest

    New: `paused` holds the motion still, and `captureRef` saves it as a PNG at any size.

More in Backgrounds

All 5 in Backgrounds
  • BackgroundsPro

    Glass Lens

    A liquid glass orb follows the pointer and bends the page behind it: your headline magnifies through it, the rim splits light into colour, and pressing swells the glass. WebGL, in your theme, with no libraries.

  • BackgroundsPro

    Liquid Metal

    Molten metal flowing behind your content, lit like a photo studio: the pointer drags ripples and carries the highlight, and a press drops a ripple. A WebGL surface in chrome, gold or iridescent, with no libraries.

  • BackgroundsPro

    Particle Logo

    Tens of thousands of particles form your word or logo, scatter from the pointer and spring back; a press sends a shock wave through them. A WebGL particle simulation in your theme colours, with no libraries.