{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink-fluid",
  "type": "registry:block",
  "title": "Ink Fluid",
  "description": "Sumi ink blooming on still water, solved as a real stable fluid (advection, vorticity, pressure) on washi paper. The cursor stirs the bath, a click lets a drop fall, and thin ink breaks into suminagashi rings.",
  "docs": "Ink Fluid was added to public/vividsites/ink-fluid.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/ink-fluid",
  "categories": [
    "3d",
    "shader-backgrounds"
  ],
  "files": [
    {
      "path": "components/vividsites/ink-fluid.html",
      "type": "registry:file",
      "target": "public/vividsites/ink-fluid.html",
      "content": "<!--\n  vs-ink-fluid — sumi ink blooming on still water (stable fluids: advection, vorticity, pressure).\n  Drops fall on their own; the cursor stirs the water; a click or tap lets a new drop fall there.\n  Thin ink breaks into the grey rings of suminagashi, and one drop in five is the accent pigment.\n\n  Fills its nearest positioned parent (position:absolute; inset:0). Put content above it.\n  CSS variables (set on .vs-ink-fluid):\n    --vs-ink-fluid-water     the water / paper colour\n    --vs-ink-fluid-ink       the main ink\n    --vs-ink-fluid-accent    the second pigment (one drop in five)\n    --vs-ink-fluid-speed     simulation speed, 1 = default\n    --vs-ink-fluid-drops     seconds between falling drops (0 = only when clicked)\n    --vs-ink-fluid-rings     strength of the suminagashi rings 0–1\n    --vs-ink-fluid-stir      how hard the cursor stirs 0–2\n    --vs-ink-fluid-fade      how fast the ink dissolves 0–1 (0 = it stays, default .25)\n    --vs-ink-fluid-brush     size of the stirring brush and of each drop, 1 = default (.4–2)\n  JS: el.dispatchEvent(new CustomEvent('vs-ink-fluid:drop', {detail:{x:.5, y:.5}})) drops ink at a point (0–1, y down).\n-->\n<div class=\"vs-ink-fluid\" aria-hidden=\"true\"><canvas class=\"vs-ink-fluid-canvas\"></canvas></div>\n<style>\n  .vs-ink-fluid {\n    --vs-ink-fluid-water: #ece6d8;\n    --vs-ink-fluid-ink: #0b0d12;\n    --vs-ink-fluid-accent: #b3321f;\n    --vs-ink-fluid-speed: 1;\n    --vs-ink-fluid-drops: 2.2;\n    --vs-ink-fluid-rings: .8;\n    --vs-ink-fluid-stir: 1;\n    --vs-ink-fluid-fade: .25;\n    --vs-ink-fluid-brush: 1;\n    position: absolute; inset: 0; overflow: hidden; pointer-events: none;\n    background: radial-gradient(40% 50% at 62% 44%, color-mix(in oklab, var(--vs-ink-fluid-ink) 70%, transparent), transparent 70%), var(--vs-ink-fluid-water);\n  }\n  .vs-ink-fluid-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 1.2s cubic-bezier(.16, 1, .3, 1); }\n  .vs-ink-fluid-ready .vs-ink-fluid-canvas { opacity: 1; }\n</style>\n<script>\n(() => {\n  /* ---- shared runtime: WebGL2 context, resize, visibility, pointer spring, CSS-variable props ---- */\n  const VS = (root, spec) => {\n    const P = spec.prefix, canvas = root.querySelector('canvas');\n    const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n    const S = { root, canvas, gl: null, w: 1, h: 1, cssW: 1, cssH: 1, dpr: 1, t: spec.t0 || 0, rt: 0, speed: 1, frame: 0, visible: false, lost: false, raf: 0, reduced: reduce.matches, css: {},\n      ptr: { x: .5, y: .5, tx: .5, ty: .5, vx: 0, vy: 0, px: .5, py: .5, down: false, inside: false, active: 0, seen: false } };\n    const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, depth: false, stencil: false, premultipliedAlpha: false, powerPreference: 'high-performance' });\n    if (!gl) { root.classList.add(P + '-fallback'); return null; }\n    S.gl = gl;\n    /* colours: any CSS colour string -> linear rgb */\n    const cc = document.createElement('canvas').getContext('2d', { willReadFrequently: true });\n    const toLin = (v) => (v <= .04045 ? v / 12.92 : Math.pow((v + .055) / 1.055, 2.4));\n    const color = (str) => { cc.clearRect(0, 0, 1, 1); cc.fillStyle = '#000'; cc.fillStyle = str; cc.fillRect(0, 0, 1, 1); const d = cc.getImageData(0, 0, 1, 1).data; return [toLin(d[0] / 255), toLin(d[1] / 255), toLin(d[2] / 255)]; };\n    const readVars = () => {\n      const cs = getComputedStyle(root);\n      for (const [k, def] of Object.entries(spec.vars || {})) {\n        const raw = cs.getPropertyValue(`--${P}-${k}`).trim() || String(def);\n        S.css[k] = typeof def === 'number' ? (parseFloat(raw) || 0) : color(raw);\n      }\n      S.speed = S.css.speed != null ? S.css.speed : 1;\n      spec.vars && spec.onVars && spec.onVars(S);\n    };\n    /* GL helpers */\n    const VERT = `#version 300 es\nvoid main(){ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); gl_Position = vec4(p * 2. - 1., 0., 1.); }`;\n    S.program = (frag, vert) => {\n      const mk = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS) && !gl.isContextLost()) console.warn(P, gl.getShaderInfoLog(s)); return s; };\n      const p = gl.createProgram(); gl.attachShader(p, mk(gl.VERTEX_SHADER, vert || VERT)); gl.attachShader(p, mk(gl.FRAGMENT_SHADER, '#version 300 es\\nprecision highp float;\\nprecision highp int;\\n' + frag)); gl.linkProgram(p);\n      const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS) || 0;\n      for (let i = 0; i < n; i++) { const a = gl.getActiveUniform(p, i); u[a.name.replace(/\\[0\\]$/, '')] = gl.getUniformLocation(p, a.name); }\n      return { p, u };\n    };\n    S.target = (w, h, o = {}) => {\n      const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);\n      const half = o.half !== false && S.hf;\n      gl.texImage2D(gl.TEXTURE_2D, 0, half ? gl.RGBA16F : gl.RGBA8, w, h, 0, gl.RGBA, half ? gl.HALF_FLOAT : gl.UNSIGNED_BYTE, null);\n      const f = o.nearest ? gl.NEAREST : gl.LINEAR;\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, o.mip ? gl.LINEAR_MIPMAP_LINEAR : f); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, f);\n      const wrap = o.repeat ? gl.REPEAT : gl.CLAMP_TO_EDGE; gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);\n      const fb = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb); 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); gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      return { t, fb, w, h, mip: !!o.mip };\n    };\n    S.pair = (w, h, o) => { const a = S.target(w, h, o), b = S.target(w, h, o); return { a, b, w, h, swap() { const x = this.a; this.a = this.b; this.b = x; } }; };\n    S.free = (...ts) => ts.forEach((x) => { if (!x) return; if (x.a) { S.free(x.a, x.b); return; } gl.deleteTexture(x.t); gl.deleteFramebuffer(x.fb); });\n    /* draw a program into a target (null = screen); tex = {name: target|texture} bound to units in order */\n    S.draw = (pr, out, uni = {}, tex = {}) => {\n      gl.useProgram(pr.p);\n      if (out) { gl.bindFramebuffer(gl.FRAMEBUFFER, out.fb); gl.viewport(0, 0, out.w, out.h); } else { gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, S.w, S.h); }\n      let unit = 0;\n      for (const [k, v] of Object.entries(tex)) { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, v && v.t !== undefined ? v.t : v); if (pr.u[k]) gl.uniform1i(pr.u[k], unit); unit++; }\n      for (const [k, v] of Object.entries(uni)) {\n        const l = pr.u[k]; if (!l) continue;\n        if (typeof v === 'number') gl.uniform1f(l, v); else if (v.length === 2) gl.uniform2fv(l, v); else if (v.length === 3) gl.uniform3fv(l, v); else if (v.length === 4) gl.uniform4fv(l, v); else gl.uniform3fv(l, v);\n      }\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n      if (out && out.mip) { gl.bindTexture(gl.TEXTURE_2D, out.t); gl.generateMipmap(gl.TEXTURE_2D); }\n    };\n    const setup = () => {\n      S.hf = !!gl.getExtension('EXT_color_buffer_float') || !!gl.getExtension('EXT_color_buffer_half_float');\n      gl.getExtension('OES_texture_float_linear');\n      gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND);\n      spec.init(S); S.sized = false; readVars(); resize();\n    };\n    const resize = () => {\n      const r = root.getBoundingClientRect(); const scale = (spec.scale ? spec.scale(S) : 1);\n      S.cssW = Math.max(1, r.width); S.cssH = Math.max(1, r.height);\n      S.dpr = Math.min(window.devicePixelRatio || 1, 2) * scale;\n      const w = Math.max(1, Math.round(S.cssW * S.dpr)), h = Math.max(1, Math.round(S.cssH * S.dpr));\n      if (w === S.w && h === S.h && S.sized) return;\n      S.w = canvas.width = w; S.h = canvas.height = h; S.aspect = w / h; S.sized = true;\n      spec.resize && spec.resize(S);\n      if (S.reduced) still();\n    };\n    /* pointer: a spring toward the finger/mouse, in root-normalised coords (0..1, y up) */\n    const P_ = S.ptr;\n    const at = (cx, cy) => { const r = root.getBoundingClientRect(); P_.tx = (cx - r.left) / Math.max(1, r.width); P_.ty = 1 - (cy - r.top) / Math.max(1, r.height); P_.inside = P_.tx >= 0 && P_.tx <= 1 && P_.ty >= 0 && P_.ty <= 1; if (!P_.seen) { P_.x = P_.px = P_.tx; P_.y = P_.py = P_.ty; P_.seen = true; } P_.active = 1; P_.last = performance.now(); };\n    addEventListener('pointermove', (e) => { if (e.pointerType !== 'touch') at(e.clientX, e.clientY); }, { passive: true });\n    addEventListener('pointerdown', (e) => { at(e.clientX, e.clientY); if (P_.inside) { P_.down = true; spec.press && spec.press(S, true); } }, { passive: true });\n    addEventListener('pointerup', () => { if (P_.down) { P_.down = false; spec.press && spec.press(S, false); } }, { passive: true });\n    addEventListener('pointercancel', () => { if (P_.down) { P_.down = false; spec.press && spec.press(S, false); } }, { passive: true });\n    addEventListener('touchstart', (e) => { const t = e.touches[0]; if (t) { P_.seen = false; at(t.clientX, t.clientY); } }, { passive: true });\n    addEventListener('touchmove', (e) => { const t = e.touches[0]; if (t) at(t.clientX, t.clientY); }, { passive: true });\n    const stepPtr = (dt) => {\n      P_.px = P_.x; P_.py = P_.y;\n      const k = spec.ptrSpring || [170, 26]; const n = Math.ceil(dt / (1 / 120));\n      for (let i = 0; i < n; i++) { const h = dt / n; P_.vx += ((P_.tx - P_.x) * k[0] - P_.vx * k[1]) * h; P_.vy += ((P_.ty - P_.y) * k[0] - P_.vy * k[1]) * h; P_.x += P_.vx * h; P_.y += P_.vy * h; }\n      P_.active = Math.max(0, P_.active - dt * .6);\n    };\n    /* loop: only while on screen, tab visible and context alive */\n    let last = 0;\n    const loop = (now) => {\n      S.raf = 0; if (!canRun()) return;\n      const dt = last ? Math.min(.05, (now - last) / 1000) : 1 / 60; last = now;\n      stepPtr(dt); S.t += dt * S.speed; S.rt += dt; S.frame++;\n      spec.frame(S, dt);\n      schedule();\n    };\n    const canRun = () => S.visible && !document.hidden && !S.lost && !S.reduced;\n    const schedule = () => { if (!S.raf && canRun()) { S.raf = requestAnimationFrame(loop); } else if (!canRun()) last = 0; };\n    const still = () => { if (S.lost) return; (spec.still || ((s) => spec.frame(s, 1 / 60)))(S); };\n    new ResizeObserver(resize).observe(root);\n    new IntersectionObserver((es) => { S.visible = es[es.length - 1].isIntersecting; schedule(); }, { rootMargin: '64px' }).observe(root);\n    document.addEventListener('visibilitychange', schedule);\n    reduce.addEventListener('change', () => { S.reduced = reduce.matches; S.reduced ? still() : schedule(); });\n    new MutationObserver(() => { readVars(); if (S.reduced) still(); }).observe(root, { attributes: true, attributeFilter: ['style', 'class'] });\n    root.addEventListener(P + ':refresh', () => { readVars(); if (S.reduced) still(); });\n    canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); S.lost = true; if (S.raf) cancelAnimationFrame(S.raf); S.raf = 0; });\n    canvas.addEventListener('webglcontextrestored', () => { S.lost = false; setup(); schedule(); });\n    S.api = { press: (on) => spec.press && spec.press(S, on) };\n    setup();\n    if (S.reduced) still();\n    root.classList.add(P + '-ready');\n    return S;\n  };\n\n  const SIM = {\n    advect: `uniform sampler2D uVel, uSrc; uniform vec2 uTexel; uniform float uDt, uDiss; out vec4 o;\n      void main(){ vec2 uv = gl_FragCoord.xy * uTexel; vec2 c = uv - uDt * texture(uVel, uv).xy * uTexel; o = uDiss * texture(uSrc, c); }`,\n    advectDye: `uniform sampler2D uVel, uSrc; uniform vec2 uTexel, uVelTexel; uniform float uDt, uDiss, uFade; out vec4 o;\n      void main(){ vec2 uv = gl_FragCoord.xy * uTexel; vec2 c = uv - uDt * texture(uVel, uv).xy * uVelTexel; o = max(uDiss * texture(uSrc, c) - uFade, 0.); }`,\n    splat: `uniform sampler2D uTarget; uniform vec2 uPoint, uTexel; uniform vec3 uColor; uniform float uRadius, uAspect; out vec4 o;\n      void main(){ vec2 uv = gl_FragCoord.xy * uTexel; vec2 p = uv - uPoint; p.x *= uAspect; vec3 s = exp(-dot(p, p) / uRadius) * uColor; o = vec4(texture(uTarget, uv).xyz + s, 1.); }`,\n    curl: `uniform sampler2D uVel; uniform vec2 uTexel; out vec4 o;\n      void main(){ vec2 uv = gl_FragCoord.xy * uTexel; float L = texture(uVel, uv - vec2(uTexel.x, 0.)).y, R = texture(uVel, uv + vec2(uTexel.x, 0.)).y, T = texture(uVel, uv + vec2(0., uTexel.y)).x, B = texture(uVel, uv - vec2(0., uTexel.y)).x;\n        o = vec4(.5 * (R - L - T + B), 0., 0., 1.); }`,\n    vort: `uniform sampler2D uVel, uCurl; uniform vec2 uTexel; uniform float uCurlK, uDt; out vec4 o;\n      void main(){ vec2 uv = gl_FragCoord.xy * uTexel; float L = texture(uCurl, uv - vec2(uTexel.x, 0.)).x, R = texture(uCurl, uv + vec2(uTexel.x, 0.)).x, T = texture(uCurl, uv + vec2(0., uTexel.y)).x, B = texture(uCurl, uv - vec2(0., uTexel.y)).x, C = texture(uCurl, uv).x;\n        vec2 f = .5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); f /= length(f) + .0001; f *= uCurlK * C; f.y *= -1.;\n        vec2 v = texture(uVel, uv).xy + f * uDt; o = vec4(clamp(v, -1000., 1000.), 0., 1.); }`,\n    div: `uniform sampler2D uVel; uniform vec2 uTexel; out vec4 o;\n      void main(){ vec2 uv = gl_FragCoord.xy * uTexel; float L = texture(uVel, uv - vec2(uTexel.x, 0.)).x, R = texture(uVel, uv + vec2(uTexel.x, 0.)).x, T = texture(uVel, uv + vec2(0., uTexel.y)).y, B = texture(uVel, uv - vec2(0., uTexel.y)).y;\n        vec2 C = texture(uVel, uv).xy; if (uv.x - uTexel.x < 0.) L = -C.x; if (uv.x + uTexel.x > 1.) R = -C.x; if (uv.y + uTexel.y > 1.) T = -C.y; if (uv.y - uTexel.y < 0.) B = -C.y;\n        o = vec4(.5 * (R - L + T - B), 0., 0., 1.); }`,\n    press: `uniform sampler2D uP, uDiv; uniform vec2 uTexel; out vec4 o;\n      void main(){ vec2 uv = gl_FragCoord.xy * uTexel; float L = texture(uP, uv - vec2(uTexel.x, 0.)).x, R = texture(uP, uv + vec2(uTexel.x, 0.)).x, T = texture(uP, uv + vec2(0., uTexel.y)).x, B = texture(uP, uv - vec2(0., uTexel.y)).x;\n        o = vec4((L + R + B + T - texture(uDiv, uv).x) * .25, 0., 0., 1.); }`,\n    grad: `uniform sampler2D uP, uVel; uniform vec2 uTexel; out vec4 o;\n      void main(){ vec2 uv = gl_FragCoord.xy * uTexel; float L = texture(uP, uv - vec2(uTexel.x, 0.)).x, R = texture(uP, uv + vec2(uTexel.x, 0.)).x, T = texture(uP, uv + vec2(0., uTexel.y)).x, B = texture(uP, uv - vec2(0., uTexel.y)).x;\n        o = vec4(texture(uVel, uv).xy - vec2(R - L, T - B), 0., 1.); }`,\n    scale: `uniform sampler2D uT; uniform vec2 uTexel; uniform float uK; out vec4 o; void main(){ o = uK * texture(uT, gl_FragCoord.xy * uTexel); }`,\n  };\n  /* the surface: ink film over paper-white water, rings in the thin ink, a soft window caught in the meniscus */\n  const VIEW = `\nuniform sampler2D uDye, uVel; uniform vec2 uRes, uDyeTexel; uniform vec3 uWater, uInk, uAccent; uniform float uTime, uRings, uFrame; out vec4 o;\nfloat h12(vec2 p){ vec3 a = fract(p.xyx * .1031); a += dot(a, a.yzx + 33.33); return fract((a.x + a.y) * a.z); }\nfloat vn(vec2 p){ vec2 i = floor(p), f = fract(p); f = f * f * (3. - 2. * f); return mix(mix(h12(i), h12(i + vec2(1, 0)), f.x), mix(h12(i + vec2(0, 1)), h12(i + 1.), f.x), f.y); }\nvec3 toSRGB(vec3 c){ c = max(c, 0.); return mix(c * 12.92, 1.055 * pow(c, vec3(1. / 2.4)) - .055, step(.0031308, c)); }\nvoid main(){\n  vec2 uv = gl_FragCoord.xy / uRes; float asp = uRes.x / uRes.y;\n  vec4 D = texture(uDye, uv);\n  vec2 e = uDyeTexel * 1.5;\n  float dx = texture(uDye, uv + vec2(e.x, 0.)).r - texture(uDye, uv - vec2(e.x, 0.)).r;\n  float dy = texture(uDye, uv + vec2(0., e.y)).r - texture(uDye, uv - vec2(0., e.y)).r;\n  vec2 grad = vec2(dx, dy);\n  // the water: a paper-white surface with long fibres beneath, refracted by the ink film's slope\n  vec2 fp = (uv + grad * .006) * vec2(asp, 1.);\n  float fib = vn(fp * vec2(9., 140.)) * .5 + vn(fp * vec2(22., 300.) + 7.) * .3 + vn(fp * 60.) * .2;\n  vec3 water = uWater * (.965 + fib * .06);\n  // light across the pond: brighter toward the upper left, cooler in the far corner\n  water *= .9 + .16 * smoothstep(1.3, 0., length((uv - vec2(.18, .92)) * vec2(asp * .6, 1.)));\n  float d = max(D.r, 0.), a = max(D.g, 0.);\n  // film: dense ink is near-black, thin ink breaks into rings (suminagashi) that follow the density contours\n  float cover = (1. - exp(-d * 6.)) * smoothstep(.006, .05, d);\n  float thin = smoothstep(.03, .09, d) * (1. - smoothstep(.35, .95, d));\n  float ringF = .5 + .5 * sin(d * 46. + a * 12.);\n  float ring = thin * smoothstep(.25, .75, ringF) * uRings;\n  float tone = cover * (1. - ring * .62);\n  vec3 col = mix(water, uInk, tone * .96);\n  // the accent pigment: a warm film that darkens where it meets the black\n  float ac = (1. - exp(-a * 3.2)) * smoothstep(.01, .07, a);\n  float aThin = smoothstep(.02, .1, a) * (1. - smoothstep(.4, 1., a));\n  ac *= 1. - aThin * smoothstep(.3, .8, .5 + .5 * sin(a * 40.)) * .45 * uRings;\n  col = mix(col, uAccent * mix(1., .55, cover), ac * .92);\n  // the meniscus: a soft window light on the slope of the film\n  vec3 n = normalize(vec3(-grad * 9., 1.));\n  float spec = pow(max(dot(reflect(vec3(0., 0., -1.), n), normalize(vec3(-.35, .5, 1.))), 0.), 60.);\n  col += vec3(1., .98, .94) * spec * .12 * smoothstep(.05, .3, d + a);\n  col = toSRGB(col);\n  vec2 v = abs(uv - .5) * 2.; col *= 1. - .16 * smoothstep(0., 1., length(max(v - .5, 0.)) / .7);\n  col += (h12(gl_FragCoord.xy + floor(uFrame) * vec2(17.3, 41.9)) + h12(gl_FragCoord.yx + floor(uFrame) * 7.7) - 1.) * .022;\n  o = vec4(clamp(col, 0., 1.), 1.);\n}`;\n  document.querySelectorAll('.vs-ink-fluid:not(.vs-ink-fluid-ready)').forEach((root) => {\n    let P = {}, vel, dye, prs, divT, curlT, sw = 1, sh = 1, dw = 1, dh = 1, queue = [], lastPress = 0, untilDrop = .6, dropN = 0, ok = true;\n    const splat = (x, y, vx, vy, ink, accent, r) => queue.push([x, y, vx, vy, ink, accent, r]);\n    /* a drop: a dense centre, a radial push, a slow swirl so it blooms instead of spreading as a disc */\n    const drop = (x, y, big = 1, accent = false) => {\n      const s = big * brush();\n      if (accent) splat(x, y, 0, 0, 0, 2.8 * s, .0026 * s); else splat(x, y, 0, 0, 3.4 * s, 0, .0026 * s);\n      const o0 = Math.random() * 6.283;\n      for (let i = 0; i < 16; i++) { const a = o0 + (i + (Math.random() - .5) * .5) / 16 * 6.283, v = (130 + Math.random() * 70) * s; splat(x + Math.cos(a) * .014, y + Math.sin(a) * .014, Math.cos(a) * v, Math.sin(a) * v, 0, 0, .0016 * s); }\n      // a slow turn so the bloom leans instead of opening as a perfect disc: one to three eddies, never the same twice\n      const spin = Math.random() < .5 ? -1 : 1, a0 = Math.random() * 6.283, arms = 1 + Math.floor(Math.random() * 3), r = .045 + Math.random() * .05, w = 45 + Math.random() * 50;\n      for (let i = 0; i < arms; i++) { const a = a0 + i * 6.283 / arms + (Math.random() - .5) * .8, sp = (i % 2 && Math.random() < .5 ? -spin : spin); splat(x + Math.cos(a) * r, y + Math.sin(a) * r, -Math.sin(a) * w * sp, Math.cos(a) * w * sp, 0, 0, .0035 + Math.random() * .002); }\n    };\n    const clearDrop = (x, y) => { for (let i = 0; i < 16; i++) { const a = i / 16 * 6.283; splat(x + Math.cos(a) * .01, y + Math.sin(a) * .01, Math.cos(a) * 230, Math.sin(a) * 230, 0, 0, .0013); } };\n    // somewhere on the pond, never the same spot twice in a row\n    let lastSpot = [.5, .5];\n    const pickSpot = () => { let p; for (let i = 0; i < 6; i++) { p = [.1 + Math.random() * .8, .12 + Math.random() * .76]; if (Math.hypot(p[0] - lastSpot[0], p[1] - lastSpot[1]) > .3) break; } lastSpot = p; return p; };\n    let Sref = null; // splats are sized against the height, so a tall phone gets smaller drops to keep the same look\n    const brush = () => Math.max(.3, Math.min(2.5, Sref ? Sref.css.brush : 1)) * (Sref && Sref.aspect < 1 ? Math.max(.5, Sref.aspect) : 1);\n    const S = VS(root, {\n      prefix: 'vs-ink-fluid',\n      vars: { water: '#ece6d8', ink: '#0b0d12', accent: '#b3321f', speed: 1, drops: 2.2, rings: .8, stir: 1, fade: .25, brush: 1 },\n      onVars(S) { Sref = S; },\n      scale: () => .75,\n      init(S) {\n        if (!S.hf) { ok = false; root.classList.add('vs-ink-fluid-fallback'); return; }\n        for (const [k, f] of Object.entries(SIM)) P[k] = S.program(f);\n        P.view = S.program(VIEW);\n      },\n      resize(S) {\n        if (!ok) return;\n        S.free(vel, dye, prs, divT, curlT);\n        const a = S.cssW / S.cssH, base = S.cssW < 700 ? 112 : 144;\n        sw = Math.round(a >= 1 ? base * a : base); sh = Math.round(a >= 1 ? base : base / a);\n        const db = Math.min(1024, Math.round(Math.max(S.cssW, S.cssH) * .7)); dw = Math.round(a >= 1 ? db : db * a); dh = Math.round(a >= 1 ? db / a : db);\n        vel = S.pair(sw, sh); prs = S.pair(sw, sh); divT = S.target(sw, sh); curlT = S.target(sw, sh); dye = S.pair(dw, dh);\n        // the pond is never empty: the first frame already shows a bath of opened drops across the frame,\n        // then a few more fall while it settles, so it reads as ink worked for a while, not a fresh splat\n        queue.length = 0;\n        const seed = [[.5, .54, 1.5], [.2, .74, 1, 0, 1], [.8, .3, 1.1], [.84, .8, .8, 1], [.24, .24, .9], [.62, .9, .7], [.08, .44, .75]];\n        seed.forEach(([x, y, s, acc, clr], i) => { drop(x, y, s, !!acc); if (i === 0 || clr) clearDrop(x, y); });\n        for (let i = 0; i < 70; i++) { if (i === 22) drop(.42, .38, .8); if (i === 40) { drop(.66, .62, .9); clearDrop(.5, .54); } step(S, 1 / 60); }\n        for (let i = 0; i < (S.reduced ? 80 : 20); i++) step(S, 1 / 60);\n      },\n      press(S, on) { const now = performance.now(); if (on && S.ptr.inside && now - lastPress > 350) lastPress = now, drop(S.ptr.tx, S.ptr.ty, .85, Math.random() < .2); },\n      frame(S, dt) {\n        if (!ok) return;\n        const Pt = S.ptr, k = S.css.stir;\n        const mx = (Pt.x - Pt.px), my = (Pt.y - Pt.py);\n        const b = brush();\n        if (Pt.inside && (mx * mx + my * my) > 1e-8) splat(Pt.x, Pt.y, Math.max(-900, Math.min(900, mx / dt * sw * .6 * k)), Math.max(-900, Math.min(900, my / dt * sh * .6 * k)), 0, 0, .0016 * b * b);\n        const iv = S.css.drops;\n        if (iv > 0) { untilDrop -= dt * S.speed; if (untilDrop <= 0) { untilDrop = iv * (.7 + Math.random() * .6); const [x, y] = pickSpot(); dropN++; drop(x, y, .7 + Math.random() * .5, dropN % 5 === 3); if (dropN % 3 === 0) setTimeout(() => clearDrop(x, y), 450); } }\n        step(S, dt * S.speed);\n        render(S);\n      },\n      still(S) { if (ok) render(S); },\n    });\n    if (!S || !ok) return;\n    root.addEventListener('vs-ink-fluid:drop', (e) => { const d = e.detail || {}; drop(d.x ?? .5, 1 - (d.y ?? .5), d.size || 1, !!d.accent); });\n    function step(S, dt) {\n      const tx = [1 / sw, 1 / sh], dtx = [1 / dw, 1 / dh];\n      for (const [x, y, vx, vy, ink, acc, r] of queue) {\n        S.draw(P.splat, vel.b, { uPoint: [x, y], uTexel: tx, uColor: [vx, vy, 0], uRadius: r, uAspect: sw / sh }, { uTarget: vel.a }); vel.swap();\n        if (ink > 0 || acc > 0) { S.draw(P.splat, dye.b, { uPoint: [x, y], uTexel: dtx, uColor: [ink, acc, 0], uRadius: r * .7, uAspect: dw / dh }, { uTarget: dye.a }); dye.swap(); }\n      }\n      queue.length = 0;\n      if (dt <= 0) return;\n      S.draw(P.curl, curlT, { uTexel: tx }, { uVel: vel.a });\n      S.draw(P.vort, vel.b, { uTexel: tx, uCurlK: 4, uDt: dt }, { uVel: vel.a, uCurl: curlT }); vel.swap();\n      S.draw(P.div, divT, { uTexel: tx }, { uVel: vel.a });\n      S.draw(P.scale, prs.b, { uTexel: tx, uK: .8 }, { uT: prs.a }); prs.swap();\n      for (let i = 0; i < 20; i++) { S.draw(P.press, prs.b, { uTexel: tx }, { uP: prs.a, uDiv: divT }); prs.swap(); }\n      S.draw(P.grad, vel.b, { uTexel: tx }, { uP: prs.a, uVel: vel.a }); vel.swap();\n      S.draw(P.advect, vel.b, { uTexel: tx, uDt: dt, uDiss: Math.pow(.972, dt * 60) }, { uVel: vel.a, uSrc: vel.a }); vel.swap();\n      S.draw(P.advectDye, dye.b, { uTexel: dtx, uVelTexel: tx, uDt: dt, uDiss: Math.pow(1 - (.0004 + Math.max(0, S.css.fade) * .0064), dt * 60), uFade: .00088 * Math.max(0, S.css.fade) * dt * 60 }, { uVel: vel.a, uSrc: dye.a }); dye.swap();\n    }\n    function render(S) {\n      S.draw(P.view, null, { uRes: [S.w, S.h], uDyeTexel: [1 / dw, 1 / dh], uWater: S.css.water, uInk: S.css.ink, uAccent: S.css.accent, uTime: S.t, uRings: S.css.rings, uFrame: Math.floor(S.rt * 24) }, { uDye: dye.a, uVel: vel.a });\n    }\n  });\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "Shader backgrounds",
    "tags": [
      "fluid",
      "ink",
      "suminagashi",
      "marbling",
      "webgl",
      "shader",
      "background",
      "interactive",
      "paper"
    ],
    "tech": [
      "WebGL2",
      "GLSL",
      "JS"
    ],
    "deps": [],
    "source": "sumi",
    "free": true,
    "accent": "#b3321f",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/ink-fluid/index.html",
    "thumbnail": "https://vividsites.app/api/component/ink-fluid/thumb.jpg",
    "video": "https://vividsites.app/api/component/ink-fluid/thumb.mp4",
    "page": "https://vividsites.app/c/ink-fluid"
  }
}