{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fluid-trail",
  "type": "registry:block",
  "title": "Fluid Trail",
  "description": "A stable-fluids simulation on the GPU that the pointer paints into, with every finger its own brush. On the paper stage the dye is mixed subtractively like watercolour, so strokes darken at their edges and blend into new colours; on the dark stage the same pigments glow.",
  "docs": "Fluid Trail was added to public/vividsites/fluid-trail.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/fluid-trail",
  "categories": [
    "3d",
    "3d-effects"
  ],
  "files": [
    {
      "path": "components/vividsites/fluid-trail.html",
      "type": "registry:file",
      "target": "public/vividsites/fluid-trail.html",
      "content": "<!--\n  vividsites · fluid-trail\n  A pigment trail that behaves like real fluid: stable-fluids on the GPU (advection,\n  curl / vorticity confinement, divergence, 20 Jacobi pressure iterations), fed by\n  the pointer's velocity. The dye is pigment, not light: on the paper stage it is\n  rendered subtractively (Beer–Lambert), so blue over yellow makes green, strokes\n  darken at their edges and settle into the paper grain like watercolour. On the\n  dark stage the same pigments glow. Multi-touch; an idle brush paints on its own.\n\n  Markup:\n    data-palette    primaries | coast | bloom | moss      (or data-pigments=\"#hex, #hex, …\")\n    data-stage      light | dark\n  Customise (CSS variables on .vs-fluid-trail):\n    --vs-fluid-trail-paper    paper colour of the light stage\n    --vs-fluid-trail-night    ground colour of the dark stage\n    --vs-fluid-trail-fade     how fast pigment fades, 0 (stays) … 2 (quick)\n    --vs-fluid-trail-swirl    vorticity: 0 (laminar) … 50 (curly)\n    --vs-fluid-trail-height   component height (default 100svh)\n  JS:\n    el.vsFluidTrail.set({ palette, stage, fade, swirl })\n    el.vsFluidTrail.splat(x, y, dx, dy)   x/y 0–1 from top-left, dx/dy a push in the same units\n  The pointer paints wherever it is over the element, even through content layered on top.\n  Touch paints too (touch-action: none) — set touch-action: pan-y on it to keep page scrolling.\n-->\n<div class=\"vs-fluid-trail\" data-palette=\"primaries\" data-stage=\"light\" aria-hidden=\"true\">\n  <canvas class=\"vs-fluid-trail-canvas\"></canvas>\n</div>\n\n<style>\n  .vs-fluid-trail {\n    --vs-fluid-trail-paper: #f3efe8;\n    --vs-fluid-trail-night: #0f1115;\n    --vs-fluid-trail-fade: .5;\n    --vs-fluid-trail-swirl: 22;\n    --vs-fluid-trail-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-fluid-trail-height); overflow: hidden; touch-action: none;\n    background: radial-gradient(30% 40% at 30% 40%, rgba(214, 72, 44, .16), transparent 70%),\n                radial-gradient(35% 45% at 70% 60%, rgba(45, 79, 214, .14), transparent 70%), var(--vs-fluid-trail-paper);\n    transition: background-color .6s cubic-bezier(.16,1,.3,1);\n  }\n  .vs-fluid-trail[data-stage=\"dark\"] { background: var(--vs-fluid-trail-night); }\n  .vs-fluid-trail-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity .8s cubic-bezier(.16,1,.3,1); }\n  .vs-fluid-trail[data-vs-state=\"ready\"] .vs-fluid-trail-canvas { opacity: 1; }\n</style>\n\n<script>\n(() => {\n  const PALETTES = {\n    primaries: ['#e8452c', '#f2b705', '#0a8a9e', '#2d4fd6'],\n    coast:     ['#1f5f8b', '#3aa0d8', '#5b8c3a', '#7a4a2a'],\n    bloom:     ['#c2417a', '#6a3fb5', '#ef6f8e', '#e0a526'],\n    moss:      ['#3f6b3a', '#c28a2c', '#a0482a', '#2b3a67'],\n  };\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n\n  const V = `precision highp float;\n  attribute vec2 aP; varying vec2 vUv, vL, vR, vT, vB; uniform vec2 texelSize;\n  void main(){ vUv = aP*.5+.5; vL = vUv - vec2(texelSize.x,0.); vR = vUv + vec2(texelSize.x,0.);\n    vT = vUv + vec2(0.,texelSize.y); vB = vUv - vec2(0.,texelSize.y); gl_Position = vec4(aP,0.,1.); }`;\n  const H = `precision highp float; precision highp sampler2D; varying vec2 vUv, vL, vR, vT, vB;`;\n  const F = {\n    clear: `${H} uniform sampler2D uTexture; uniform float value; void main(){ gl_FragColor = value*texture2D(uTexture, vUv); }`,\n    /* cover > 0: fresh pigment partly displaces what is under it (like gouache), so colours stay clean instead of going to mud */\n    splat: `${H} uniform sampler2D uTarget; uniform float aspectRatio, radius, cover; uniform vec3 color; uniform vec2 point;\n      void main(){ vec2 p = vUv - point; p.x *= aspectRatio; float g = exp(-dot(p,p)/radius);\n        gl_FragColor = vec4(texture2D(uTarget, vUv).xyz*(1. - g*cover) + g*color, 1.); }`,\n    advect: `${H} uniform sampler2D uVelocity, uSource; uniform vec2 texelSize, dyeTexelSize; uniform float dt, dissipation;\n      vec4 bilerp(sampler2D s, vec2 uv, vec2 ts){ vec2 st = uv/ts - .5; vec2 i = floor(st), f = fract(st);\n        vec4 a = texture2D(s, (i+vec2(.5,.5))*ts), b = texture2D(s, (i+vec2(1.5,.5))*ts), c = texture2D(s, (i+vec2(.5,1.5))*ts), d = texture2D(s, (i+vec2(1.5,1.5))*ts);\n        return mix(mix(a,b,f.x), mix(c,d,f.x), f.y); }\n      void main(){\n      #ifdef MANUAL\n        vec2 coord = vUv - dt*bilerp(uVelocity, vUv, texelSize).xy*texelSize; vec4 r = bilerp(uSource, coord, dyeTexelSize);\n      #else\n        vec2 coord = vUv - dt*texture2D(uVelocity, vUv).xy*texelSize; vec4 r = texture2D(uSource, coord);\n      #endif\n        gl_FragColor = r/(1. + dissipation*dt); }`,\n    divergence: `${H} uniform sampler2D uVelocity;\n      void main(){ float L = texture2D(uVelocity, vL).x, R = texture2D(uVelocity, vR).x, T = texture2D(uVelocity, vT).y, B = texture2D(uVelocity, vB).y;\n        vec2 C = texture2D(uVelocity, vUv).xy;\n        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;\n        gl_FragColor = vec4(.5*(R-L+T-B), 0., 0., 1.); }`,\n    curl: `${H} uniform sampler2D uVelocity;\n      void main(){ float L = texture2D(uVelocity, vL).y, R = texture2D(uVelocity, vR).y, T = texture2D(uVelocity, vT).x, B = texture2D(uVelocity, vB).x;\n        gl_FragColor = vec4(.5*(R-L-T+B), 0., 0., 1.); }`,\n    vorticity: `${H} uniform sampler2D uVelocity, uCurl; uniform float curl, dt;\n      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;\n        vec2 force = .5*vec2(abs(T)-abs(B), abs(R)-abs(L)); force /= length(force) + .0001; force *= curl*C; force.y *= -1.;\n        vec2 v = texture2D(uVelocity, vUv).xy + force*dt; gl_FragColor = vec4(clamp(v, -1000., 1000.), 0., 1.); }`,\n    pressure: `${H} uniform sampler2D uPressure, uDivergence;\n      void main(){ float L = texture2D(uPressure, vL).x, R = texture2D(uPressure, vR).x, T = texture2D(uPressure, vT).x, B = texture2D(uPressure, vB).x;\n        gl_FragColor = vec4((L+R+B+T - texture2D(uDivergence, vUv).x)*.25, 0., 0., 1.); }`,\n    gradient: `${H} uniform sampler2D uPressure, uVelocity;\n      void main(){ float L = texture2D(uPressure, vL).x, R = texture2D(uPressure, vR).x, T = texture2D(uPressure, vT).x, B = texture2D(uPressure, vB).x;\n        gl_FragColor = vec4(texture2D(uVelocity, vUv).xy - vec2(R-L, T-B), 0., 1.); }`,\n    /* pigment → colour. light: paper × exp(−absorbance), with edge darkening and grain. dark: the pigments glow */\n    display: `${H} uniform sampler2D uTexture; uniform vec2 texelSize; uniform vec3 uPaper, uNight; uniform float uDark, uScale;\n      float h(vec2 p){ p = fract(p*vec2(443.897, 441.423)); p += dot(p, p.yx+19.19); return fract((p.x+p.y)*p.x); }\n      float n2(vec2 p){ vec2 i = floor(p), f = fract(p); f = f*f*(3.-2.*f);\n        return mix(mix(h(i), h(i+vec2(1,0)), f.x), mix(h(i+vec2(0,1)), h(i+vec2(1,1)), f.x), f.y); }\n      float dens(vec2 uv){ vec3 a = texture2D(uTexture, uv).rgb; return a.r + a.g + a.b; }\n      void main(){\n        vec3 A = max(texture2D(uTexture, vUv).rgb, 0.);\n        vec2 e = texelSize*1.5;\n        float g = length(vec2(dens(vUv+vec2(e.x,0.)) - dens(vUv-vec2(e.x,0.)), dens(vUv+vec2(0.,e.y)) - dens(vUv-vec2(0.,e.y))));\n        vec2 fc = gl_FragCoord.xy/uScale;\n        float grain = n2(fc*.55)*.6 + n2(fc*1.7)*.4;\n        float fibre = n2(fc*vec2(.08, 1.3))*.5 + n2(fc*vec2(1.1, .07))*.5;\n        float edge = clamp(g*1.4, 0., 1.1) * smoothstep(0., .6, A.r+A.g+A.b);\n        A *= (1. + edge*.9) * (.8 + .4*grain);\n        A *= smoothstep(.015, .3, A.r + A.g + A.b);\n        A = 2.2*(1. - exp(-A/2.2));\n        vec3 T = exp(-A);\n        vec3 col;\n        if (uDark < .5) {\n          col = uPaper * T * (.975 + .035*fibre);\n        } else {\n          float d = 1. - (T.r + T.g + T.b)/3.;\n          vec3 hue = T / max(max(T.r, T.g), max(T.b, .002));\n          hue = clamp(mix(vec3(dot(hue, vec3(.2126, .7152, .0722))), hue, 1.45), 0., 1.);\n          col = uNight*(.94 + .08*fibre) + hue*(1. - exp(-d*2.6))*.95;\n        }\n        col += (h(gl_FragCoord.xy + fract(uPaper.r*91.7)) - .5)/255.;\n        gl_FragColor = vec4(col, 1.);\n      }`,\n  };\n\n  const hexRgb = s => { const cv = document.createElement('canvas').getContext('2d'); cv.fillStyle = '#000'; cv.fillStyle = s; const h = cv.fillStyle;\n    return h[0] === '#' ? [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255) : (h.match(/[\\d.]+/g) || [0, 0, 0]).slice(0, 3).map(x => x / 255); };\n  /* a pigment's absorbance: the colour it leaves on white paper, as −ln(transmittance) */\n  const absorb = hex => { const a = hexRgb(hex).map(c => -Math.log(Math.max(.03, Math.min(.985, c))));\n    /* even out pale pigments (yellow) against dark ones (ultramarine) so every colour reads at the same weight */\n    const t = a.map(v => Math.exp(-v)), strength = 1 - (.2126 * t[0] + .7152 * t[1] + .0722 * t[2]);\n    const k = Math.min(1.8, Math.max(.7, .6 / Math.max(.05, strength))); return a.map(v => v * k); };\n\n  function mount(root) {\n    if (root.vsFluidTrail) return;\n    const canvas = root.querySelector('.vs-fluid-trail-canvas');\n    const attrs = { alpha: false, depth: false, stencil: false, antialias: false, preserveDrawingBuffer: false, powerPreference: 'high-performance' };\n    let gl = canvas.getContext('webgl2', attrs), isGL2 = !!gl;\n    if (!gl) gl = canvas.getContext('webgl', attrs) || canvas.getContext('experimental-webgl', attrs);\n    if (!gl) { root.dataset.vsState = 'nogl'; return; }\n\n    let ext, P = {}, dye, vel, divergence, curlT, pressure, raf = 0, visible = true, lost = false, last = 0, dpr = 1;\n    const props = { pigments: [], paper: [.95, .94, .91], night: [.06, .07, .08], dark: 0, fade: .5, swirl: 22 };\n    const pointers = new Map();\n    let pig = 0, idleT = 0, auto = { t: Math.random() * 100, x: .5, y: .5, on: true, lastSwap: 0 };\n\n    function getExt() {\n      let halfFloat, linear;\n      if (isGL2) { gl.getExtension('EXT_color_buffer_float'); linear = gl.getExtension('OES_texture_float_linear'); halfFloat = gl.HALF_FLOAT; }\n      else { const hf = gl.getExtension('OES_texture_half_float'); linear = gl.getExtension('OES_texture_half_float_linear'); halfFloat = hf && hf.HALF_FLOAT_OES; }\n      if (!halfFloat) return null;\n      const fmt = (i, f) => { if (!isGL2) return { i: gl.RGBA, f: gl.RGBA };\n        const tries = [[i, f], [gl.RG16F, gl.RG], [gl.RGBA16F, gl.RGBA]]; for (const [a, b] of tries) if (supports(a, b, halfFloat)) return { i: a, f: b }; return null; };\n      const rgba = isGL2 ? (supports(gl.RGBA16F, gl.RGBA, halfFloat) ? { i: gl.RGBA16F, f: gl.RGBA } : null) : (supports(gl.RGBA, gl.RGBA, halfFloat) ? { i: gl.RGBA, f: gl.RGBA } : null);\n      if (!rgba) return null;\n      const rg = isGL2 ? fmt(gl.RG16F, gl.RG) : rgba, r = isGL2 ? fmt(gl.R16F, gl.RED) : rgba;\n      if (!rg || !r) return null;\n      /* half-float is filterable in WebGL2; WebGL1 needs the extension or manual bilerp */\n      return { halfFloat, rgba, rg, r, linear: isGL2 ? true : !!linear };\n    }\n    function supports(internal, format, type) {\n      const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);\n      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);\n      gl.texImage2D(gl.TEXTURE_2D, 0, internal, 4, 4, 0, format, type, null);\n      const f = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, f); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);\n      const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.deleteTexture(t); gl.deleteFramebuffer(f); return ok;\n    }\n    function compile(type, src) { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);\n      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS) && !gl.isContextLost()) throw new Error(gl.getShaderInfoLog(s)); return s; }\n    function program(fs) {\n      const p = gl.createProgram(); gl.attachShader(p, compile(gl.VERTEX_SHADER, V));\n      gl.attachShader(p, compile(gl.FRAGMENT_SHADER, (ext.linear ? '' : '#define MANUAL\\n') + fs));\n      gl.bindAttribLocation(p, 0, 'aP'); gl.linkProgram(p);\n      if (!gl.getProgramParameter(p, gl.LINK_STATUS) && !gl.isContextLost()) throw new Error(gl.getProgramInfoLog(p));\n      const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);\n      for (let i = 0; i < n; i++) { const k = gl.getActiveUniform(p, i).name; u[k] = gl.getUniformLocation(p, k); }\n      return { p, u };\n    }\n    function fbo(w, h, fmt, filter) {\n      gl.activeTexture(gl.TEXTURE0);\n      const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);\n      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);\n      gl.texImage2D(gl.TEXTURE_2D, 0, fmt.i, w, h, 0, fmt.f, ext.halfFloat, null);\n      const f = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, f); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);\n      gl.viewport(0, 0, w, h); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT);\n      return { t, f, w, h, tx: [1 / w, 1 / h], attach(id) { gl.activeTexture(gl.TEXTURE0 + id); gl.bindTexture(gl.TEXTURE_2D, t); return id; } };\n    }\n    const double = (w, h, fmt, filter) => { let a = fbo(w, h, fmt, filter), b = fbo(w, h, fmt, filter);\n      return { w, h, tx: a.tx, get read() { return a; }, get write() { return b; }, swap() { const t = a; a = b; b = t; } }; };\n    function res(n) { const a = canvas.width / canvas.height; const big = Math.round(n * Math.max(a, 1 / a)), small = Math.round(n);\n      return canvas.width > canvas.height ? [big, small] : [small, big]; }\n    function buildTargets() {\n      const filter = ext.linear ? gl.LINEAR : gl.NEAREST;\n      const [sw, sh] = res(128); const small = Math.min(canvas.width, canvas.height) < 700;\n      const [dw, dh] = res(small ? 512 : 720);\n      gl.disable(gl.BLEND);\n      dye = double(dw, dh, ext.rgba, filter); vel = double(sw, sh, ext.rg, filter);\n      divergence = fbo(sw, sh, ext.r, gl.NEAREST); curlT = fbo(sw, sh, ext.r, gl.NEAREST); pressure = double(sw, sh, ext.r, gl.NEAREST);\n    }\n    function init() {\n      ext = getExt(); if (!ext) throw new Error('no half-float render targets');\n      for (const k in F) P[k] = program(F[k]);\n      const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b);\n      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW);\n      const e = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, e);\n      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);\n      gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);\n      resize(true);\n    }\n    function blit(target) {\n      if (target) { gl.viewport(0, 0, target.w, target.h); gl.bindFramebuffer(gl.FRAMEBUFFER, target.f); }\n      else { gl.viewport(0, 0, canvas.width, canvas.height); gl.bindFramebuffer(gl.FRAMEBUFFER, null); }\n      gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);\n    }\n    const use = n => { gl.useProgram(P[n].p); return P[n].u; };\n\n    function splat(x, y, dx, dy, color, rad = 1) {\n      const a = canvas.width / canvas.height;\n      let r = .0022 * rad; if (a > 1) r *= a;\n      if (a < 1) { dx *= a; r *= .7; } /* portrait: sideways force and brush size follow the narrow width */\n      let u = use('splat');\n      gl.uniform1i(u.uTarget, vel.read.attach(0)); gl.uniform1f(u.aspectRatio, a); gl.uniform2f(u.point, x, y);\n      gl.uniform3f(u.color, dx, dy, 0); gl.uniform1f(u.radius, r); gl.uniform1f(u.cover, 0); blit(vel.write); vel.swap();\n      gl.uniform1i(u.uTarget, dye.read.attach(0)); gl.uniform3f(u.color, color[0], color[1], color[2]); gl.uniform1f(u.radius, r * .8);\n      gl.uniform1f(u.cover, props.dark ? .1 : .26); blit(dye.write); dye.swap();\n    }\n    const pigmentAt = (i, amt) => props.pigments[((i % props.pigments.length) + props.pigments.length) % props.pigments.length].map(v => v * amt);\n\n    function step(dt) {\n      gl.disable(gl.BLEND);\n      let u = use('curl'); gl.uniform2f(u.texelSize, ...vel.tx); gl.uniform1i(u.uVelocity, vel.read.attach(0)); blit(curlT);\n      u = use('vorticity'); gl.uniform2f(u.texelSize, ...vel.tx); gl.uniform1i(u.uVelocity, vel.read.attach(0)); gl.uniform1i(u.uCurl, curlT.attach(1));\n      gl.uniform1f(u.curl, reduce.matches ? props.swirl * .3 : props.swirl); gl.uniform1f(u.dt, dt); blit(vel.write); vel.swap();\n      u = use('divergence'); gl.uniform2f(u.texelSize, ...vel.tx); gl.uniform1i(u.uVelocity, vel.read.attach(0)); blit(divergence);\n      u = use('clear'); gl.uniform1i(u.uTexture, pressure.read.attach(0)); gl.uniform1f(u.value, .8); blit(pressure.write); pressure.swap();\n      u = use('pressure'); gl.uniform2f(u.texelSize, ...vel.tx); gl.uniform1i(u.uDivergence, divergence.attach(0));\n      for (let i = 0; i < 20; i++) { gl.uniform1i(u.uPressure, pressure.read.attach(1)); blit(pressure.write); pressure.swap(); }\n      u = use('gradient'); gl.uniform2f(u.texelSize, ...vel.tx); gl.uniform1i(u.uPressure, pressure.read.attach(0)); gl.uniform1i(u.uVelocity, vel.read.attach(1)); blit(vel.write); vel.swap();\n      u = use('advect'); gl.uniform2f(u.texelSize, ...vel.tx); if (!ext.linear) gl.uniform2f(u.dyeTexelSize, ...vel.tx);\n      const v = vel.read.attach(0); gl.uniform1i(u.uVelocity, v); gl.uniform1i(u.uSource, v); gl.uniform1f(u.dt, dt); gl.uniform1f(u.dissipation, .9); blit(vel.write); vel.swap();\n      if (!ext.linear) gl.uniform2f(u.dyeTexelSize, ...dye.tx);\n      gl.uniform1i(u.uVelocity, vel.read.attach(0)); gl.uniform1i(u.uSource, dye.read.attach(1)); gl.uniform1f(u.dissipation, props.fade); blit(dye.write); dye.swap();\n    }\n    function render() {\n      const u = use('display'); gl.uniform2f(u.texelSize, ...dye.tx); gl.uniform1i(u.uTexture, dye.read.attach(0));\n      gl.uniform3f(u.uPaper, ...props.paper); gl.uniform3f(u.uNight, ...props.night); gl.uniform1f(u.uDark, props.dark); gl.uniform1f(u.uScale, dpr);\n      blit(null);\n    }\n\n    /* the idle brush: a wandering, speed-varying stroke that swaps pigment now and then */\n    function autoBrush(dt, now) {\n      auto.t += dt * .55;\n      const t = auto.t, portrait = canvas.width < canvas.height;\n      const x = .5 + (portrait ? .26 : .34) * Math.sin(t * 1.13 + Math.sin(t * .37) * 1.6) + .08 * Math.sin(t * 3.1);\n      const y = .5 + .3 * Math.sin(t * .83 + 1.3) * Math.cos(t * .29) + .06 * Math.cos(t * 2.3);\n      const dx = x - auto.x, dy = y - auto.y; auto.x = x; auto.y = y;\n      if (!auto.on) { auto.on = true; return; }\n      if (now - auto.lastSwap > 1700) { auto.lastSwap = now; pig++; }\n      const sp = Math.hypot(dx, dy);\n      if (sp > 0) splat(x, 1 - y, dx * 5200, -dy * 5200, pigmentAt(pig, (.16 + Math.min(.14, sp * 18)) * (portrait ? .55 : 1)), .9);\n    }\n    function frame(now) {\n      raf = 0; if (lost) return;\n      const dt = Math.min(.0166, (now - (last || now)) / 1000) || .0166; last = now;\n      for (const p of pointers.values()) {\n        if (!p.moved) continue; p.moved = false;\n        const sp = Math.hypot(p.dx, p.dy * canvas.height / canvas.width);\n        /* long jumps (fast strokes, slow frames) are split into steps so the trail stays continuous and frame-rate independent */\n        const n = Math.min(8, Math.max(1, Math.ceil(sp / .014))), col = pigmentAt(p.pig, .15 + Math.min(.15, sp / n * 10));\n        for (let i = n - 1; i >= 0; i--) splat(p.x - p.dx * i / n, p.y - p.dy * i / n, p.dx / n * 5000, p.dy / n * 5000, col, p.touch ? 1.6 : 1);\n        p.dx = 0; p.dy = 0;\n      }\n      idleT += dt;\n      if (idleT > 2.2 && !reduce.matches) autoBrush(dt, now); else auto.on = false;\n      step(dt); render();\n      if (root.dataset.vsState !== 'ready') root.dataset.vsState = 'ready';\n      if (visible) raf = requestAnimationFrame(frame);\n    }\n    const kick = () => { if (!raf && visible && !lost) { last = 0; raf = requestAnimationFrame(frame); } };\n\n    function resize(force) {\n      dpr = Math.min(2, devicePixelRatio || 1);\n      const w = Math.max(1, Math.round(root.clientWidth * dpr)), h = Math.max(1, Math.round(root.clientHeight * dpr));\n      if (!force && w === canvas.width && h === canvas.height) return;\n      const first = !dye; canvas.width = w; canvas.height = h; buildTargets();\n      if (first || force) seed();\n    }\n    /* a few strokes already on the paper, so it never opens blank */\n    function seed() {\n      const n = 5;\n      for (let i = 0; i < n; i++) {\n        const x = .18 + Math.random() * .64, y = .2 + Math.random() * .6, a = Math.random() * 6.28;\n        for (let k = 0; k < 14; k++) splat(x + Math.cos(a) * k * .008, y + Math.sin(a) * k * .008, Math.cos(a) * 900, Math.sin(a) * 900, pigmentAt(i, .08), 1.2);\n      }\n    }\n    function readProps() {\n      const cs = getComputedStyle(root);\n      const list = root.dataset.pigments ? root.dataset.pigments.split(',').map(s => s.trim()).filter(Boolean) : PALETTES[root.dataset.palette] || PALETTES.primaries;\n      props.pigments = list.map(absorb);\n      props.paper = hexRgb(cs.getPropertyValue('--vs-fluid-trail-paper').trim() || '#f3efe8');\n      props.night = hexRgb(cs.getPropertyValue('--vs-fluid-trail-night').trim() || '#0f1115');\n      props.dark = root.dataset.stage === 'dark' ? 1 : 0;\n      const f = parseFloat(cs.getPropertyValue('--vs-fluid-trail-fade')); props.fade = Number.isFinite(f) ? f : .5;\n      const s = parseFloat(cs.getPropertyValue('--vs-fluid-trail-swirl')); props.swirl = Number.isFinite(s) ? s : 22;\n    }\n    function apply(o = {}) {\n      if (o.palette != null) { root.dataset.palette = o.palette; delete root.dataset.pigments; }\n      if (o.stage != null) root.dataset.stage = o.stage;\n      if (o.fade != null) root.style.setProperty('--vs-fluid-trail-fade', o.fade);\n      if (o.swirl != null) root.style.setProperty('--vs-fluid-trail-swirl', o.swirl);\n      readProps();\n    }\n\n    /* input: every pointer is its own brush; listen on window so layered content doesn't block it */\n    const inside = e => { const r = root.getBoundingClientRect(); return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom ? r : null; };\n    addEventListener('pointermove', e => {\n      const r = inside(e); if (!r) { pointers.delete(e.pointerId); return; }\n      const x = (e.clientX - r.left) / r.width, y = 1 - (e.clientY - r.top) / r.height;\n      let p = pointers.get(e.pointerId);\n      if (!p) { p = { x, y, dx: 0, dy: 0, moved: false, pig: pig++, touch: e.pointerType === 'touch', born: performance.now() }; pointers.set(e.pointerId, p); return; }\n      p.dx += x - p.x; p.dy += y - p.y; p.x = x; p.y = y; p.moved = true; idleT = 0;\n      if (performance.now() - p.born > 800) { p.pig = pig++; p.born = performance.now(); }\n      kick();\n    }, { passive: true });\n    addEventListener('pointerdown', e => { if (!inside(e)) return; const p = pointers.get(e.pointerId); if (p) { p.pig = pig++; p.born = performance.now(); } idleT = 0; });\n    const drop = e => { if (e.pointerType !== 'mouse') pointers.delete(e.pointerId); };\n    addEventListener('pointerup', drop, { passive: true }); addEventListener('pointercancel', drop, { passive: true });\n\n    canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; cancelAnimationFrame(raf); raf = 0; root.dataset.vsState = 'lost'; });\n    canvas.addEventListener('webglcontextrestored', () => { lost = false; try { P = {}; dye = null; init(); kick(); } catch { root.dataset.vsState = 'nogl'; } });\n    new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) kick(); }).observe(root);\n    document.addEventListener('visibilitychange', () => { visible = !document.hidden; if (visible) kick(); });\n    new MutationObserver(readProps).observe(root, { attributes: true, attributeFilter: ['style', 'class', 'data-palette', 'data-pigments', 'data-stage'] });\n\n    readProps();\n    try { init(); } catch (err) { root.dataset.vsState = 'nogl'; console.warn('[fluid-trail]', err.message); return; }\n    new ResizeObserver(() => resize(false)).observe(root);\n    root.vsFluidTrail = { set: apply, splat: (x, y, dx = 0, dy = 0) => { splat(x, 1 - y, dx * 6000, -dy * 6000, pigmentAt(pig++, .3), 1.4); kick(); } };\n    root.dispatchEvent(new CustomEvent('vs:ready'));\n    kick();\n  }\n  const boot = () => document.querySelectorAll('.vs-fluid-trail').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D effects",
    "tags": [
      "fluid",
      "cursor",
      "trail",
      "ink",
      "watercolour",
      "simulation",
      "navier-stokes",
      "background"
    ],
    "tech": [
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#e8452c",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/fluid-trail/index.html",
    "thumbnail": "https://vividsites.app/api/component/fluid-trail/thumb.jpg",
    "video": "https://vividsites.app/api/component/fluid-trail/thumb.mp4",
    "page": "https://vividsites.app/c/fluid-trail"
  }
}