{
  "name": "ink-background",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Ink Background\n * @version: 1.0.0\n * @date: 2026-10-21\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface InkBackgroundProps {\n  /** The headline written in ink. It bleeds when stirred and gathers again. */\n  text?: string;\n  /**\n   * Color of the ink a click drops. Leave it out for the same ink as the\n   * headline.\n   */\n  accent?: string;\n  /** Content over the ink, under the headline. */\n  children?: ReactNode;\n  /** Set false for a still backdrop. */\n  interactive?: boolean;\n  className?: string;\n}\n\n// The velocity field is solved on a grid this many cells on its short side;\n// the ink is drawn on a finer one.\nconst SIM = 128;\nconst DYE = 512;\nconst PRESSURE_ITERATIONS = 20;\n// How fast stirred ink fades away, and how fast velocity calms down.\nconst DYE_FADE = 0.35;\nconst VELOCITY_FADE = 1.2;\n// Swirl kept in the flow, so stirring curls instead of smearing.\nconst CURL = 22;\n// The headline pulls its ink back at this rate per frame once left alone,\n// after this long.\nconst GATHER = 0.06;\nconst GATHER_AFTER = 1200;\n// The loop stops after this long with nothing moving it.\nconst SLEEP_AFTER = 5000;\nconst STIR_FORCE = 5200;\nconst STIR_RADIUS = 0.0018;\nconst DROP_RADIUS = 0.0035;\n\nconst VERTEX = `#version 300 es\nin vec2 aPos;\nout vec2 vUv;\nout vec2 vL;\nout vec2 vR;\nout vec2 vT;\nout vec2 vB;\nuniform vec2 texel;\nvoid main() {\n  vUv = aPos * 0.5 + 0.5;\n  vL = vUv - vec2(texel.x, 0.0);\n  vR = vUv + vec2(texel.x, 0.0);\n  vT = vUv + vec2(0.0, texel.y);\n  vB = vUv - vec2(0.0, texel.y);\n  gl_Position = vec4(aPos, 0.0, 1.0);\n}`;\n\nconst HEAD = `#version 300 es\nprecision highp float;\nprecision highp sampler2D;\nin vec2 vUv;\nin vec2 vL;\nin vec2 vR;\nin vec2 vT;\nin vec2 vB;\nout vec4 o;\n`;\n\nconst SHADERS = {\n  splat: `${HEAD}\nuniform sampler2D uTarget;\nuniform float aspect;\nuniform vec3 color;\nuniform vec2 point;\nuniform float radius;\nvoid main() {\n  vec2 p = vUv - point;\n  p.x *= aspect;\n  vec3 s = exp(-dot(p, p) / radius) * color;\n  o = vec4(texture(uTarget, vUv).xyz + s, 1.0);\n}`,\n  advect: `${HEAD}\nuniform sampler2D uVelocity;\nuniform sampler2D uSource;\nuniform vec2 simTexel;\nuniform float dt;\nuniform float fade;\nvoid main() {\n  vec2 coord = vUv - dt * texture(uVelocity, vUv).xy * simTexel;\n  o = texture(uSource, coord) / (1.0 + fade * dt);\n}`,\n  divergence: `${HEAD}\nuniform sampler2D uVelocity;\nvoid main() {\n  float l = texture(uVelocity, vL).x;\n  float r = texture(uVelocity, vR).x;\n  float t = texture(uVelocity, vT).y;\n  float b = texture(uVelocity, vB).y;\n  vec2 c = texture(uVelocity, vUv).xy;\n  if (vL.x < 0.0) { l = -c.x; }\n  if (vR.x > 1.0) { r = -c.x; }\n  if (vT.y > 1.0) { t = -c.y; }\n  if (vB.y < 0.0) { b = -c.y; }\n  o = vec4(0.5 * (r - l + t - b), 0.0, 0.0, 1.0);\n}`,\n  curl: `${HEAD}\nuniform sampler2D uVelocity;\nvoid main() {\n  float l = texture(uVelocity, vL).y;\n  float r = texture(uVelocity, vR).y;\n  float t = texture(uVelocity, vT).x;\n  float b = texture(uVelocity, vB).x;\n  o = vec4(0.5 * (r - l - t + b), 0.0, 0.0, 1.0);\n}`,\n  vorticity: `${HEAD}\nuniform sampler2D uVelocity;\nuniform sampler2D uCurl;\nuniform float curl;\nuniform float dt;\nvoid main() {\n  float l = texture(uCurl, vL).x;\n  float r = texture(uCurl, vR).x;\n  float t = texture(uCurl, vT).x;\n  float b = texture(uCurl, vB).x;\n  float c = texture(uCurl, vUv).x;\n  vec2 force = 0.5 * vec2(abs(t) - abs(b), abs(r) - abs(l));\n  force /= length(force) + 0.0001;\n  force *= curl * c;\n  force.y *= -1.0;\n  vec2 v = texture(uVelocity, vUv).xy + force * dt;\n  o = vec4(clamp(v, -1000.0, 1000.0), 0.0, 1.0);\n}`,\n  pressure: `${HEAD}\nuniform sampler2D uPressure;\nuniform sampler2D uDivergence;\nvoid main() {\n  float l = texture(uPressure, vL).x;\n  float r = texture(uPressure, vR).x;\n  float t = texture(uPressure, vT).x;\n  float b = texture(uPressure, vB).x;\n  float d = texture(uDivergence, vUv).x;\n  o = vec4((l + r + b + t - d) * 0.25, 0.0, 0.0, 1.0);\n}`,\n  gradient: `${HEAD}\nuniform sampler2D uPressure;\nuniform sampler2D uVelocity;\nvoid main() {\n  float l = texture(uPressure, vL).x;\n  float r = texture(uPressure, vR).x;\n  float t = texture(uPressure, vT).x;\n  float b = texture(uPressure, vB).x;\n  vec2 v = texture(uVelocity, vUv).xy - vec2(r - l, t - b);\n  o = vec4(v, 0.0, 1.0);\n}`,\n  scale: `${HEAD}\nuniform sampler2D uTarget;\nuniform float value;\nvoid main() {\n  o = value * texture(uTarget, vUv);\n}`,\n  // Pulls the headline's ink (red channel) back toward the letters. Dropped\n  // ink (green) is left to drift and fade.\n  gather: `${HEAD}\nuniform sampler2D uDye;\nuniform sampler2D uMask;\nuniform float rate;\nvoid main() {\n  vec4 d = texture(uDye, vUv);\n  float m = texture(uMask, vUv).r;\n  o = vec4(mix(d.r, m, rate), d.g, 0.0, 1.0);\n}`,\n  display: `${HEAD}\nuniform sampler2D uDye;\nuniform vec3 paper;\nuniform vec3 ink;\nuniform vec3 accent;\nvoid main() {\n  vec4 d = texture(uDye, vUv);\n  // A soft knee, so thin washes stay pale and dense ink goes solid.\n  float a = smoothstep(0.02, 0.85, d.r);\n  float b = smoothstep(0.02, 0.85, d.g);\n  vec3 c = mix(paper, ink, a);\n  c = mix(c, accent, b * 0.92);\n  o = vec4(c, 1.0);\n}`,\n};\n\ntype ProgramName = keyof typeof SHADERS;\n\ninterface Target {\n  tex: WebGLTexture;\n  fbo: WebGLFramebuffer;\n  w: number;\n  h: number;\n}\n\ninterface Double {\n  read: Target;\n  write: Target;\n  swap: () => void;\n}\n\nfunction compile(gl: WebGL2RenderingContext, type: number, source: string) {\n  const shader = gl.createShader(type);\n  if (!shader) {\n    return null;\n  }\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    gl.deleteShader(shader);\n    return null;\n  }\n  return shader;\n}\n\ninterface Program {\n  program: WebGLProgram;\n  uniforms: Record<string, WebGLUniformLocation | null>;\n}\n\nfunction link(gl: WebGL2RenderingContext, vertex: WebGLShader, source: string) {\n  const fragment = compile(gl, gl.FRAGMENT_SHADER, source);\n  const program = gl.createProgram();\n  if (!(fragment && program)) {\n    return null;\n  }\n  gl.attachShader(program, vertex);\n  gl.attachShader(program, fragment);\n  gl.bindAttribLocation(program, 0, \"aPos\");\n  gl.linkProgram(program);\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    return null;\n  }\n  const uniforms: Program[\"uniforms\"] = {};\n  const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);\n  for (let i = 0; i < count; i++) {\n    const info = gl.getActiveUniform(program, i);\n    if (info) {\n      uniforms[info.name] = gl.getUniformLocation(program, info.name);\n    }\n  }\n  return { program, uniforms };\n}\n\n/** Parses any CSS color, oklch included, by painting it into one pixel. */\nfunction toRgb(color: string, probe: CanvasRenderingContext2D) {\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = color;\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;\n  return [r / 255, g / 255, b / 255];\n}\n\ninterface Fluid {\n  resize: (w: number, h: number) => void;\n  setMask: (source: HTMLCanvasElement) => void;\n  setColors: (paper: number[], ink: number[], accent: number[]) => void;\n  splat: (\n    x: number,\n    y: number,\n    vx: number,\n    vy: number,\n    ink: number[],\n    radius: number\n  ) => void;\n  step: (dt: number, gather: number) => void;\n  draw: () => void;\n  destroy: () => void;\n}\n\n/**\n * A small stable-fluids solver (semi-Lagrangian advection, vorticity\n * confinement, Jacobi pressure), in the spirit of Jos Stam's paper and\n * Pavel Dobryakov's WebGL port. Returns null where half-float render\n * targets aren't available.\n */\nfunction createFluid(gl: WebGL2RenderingContext): Fluid | null {\n  if (!gl.getExtension(\"EXT_color_buffer_float\")) {\n    return null;\n  }\n  gl.getExtension(\"OES_texture_float_linear\");\n  const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);\n  if (!vertex) {\n    return null;\n  }\n  const programs = {} as Record<ProgramName, Program>;\n  for (const name of Object.keys(SHADERS) as ProgramName[]) {\n    const made = link(gl, vertex, SHADERS[name]);\n    if (!made) {\n      return null;\n    }\n    programs[name] = made;\n  }\n\n  const quad = gl.createBuffer();\n  gl.bindBuffer(gl.ARRAY_BUFFER, quad);\n  gl.bufferData(\n    gl.ARRAY_BUFFER,\n    new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),\n    gl.STATIC_DRAW\n  );\n  gl.enableVertexAttribArray(0);\n  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n\n  const textures: WebGLTexture[] = [];\n  const framebuffers: WebGLFramebuffer[] = [];\n\n  const target = (\n    w: number,\n    h: number,\n    internal: number,\n    format: number\n  ): Target => {\n    const tex = gl.createTexture() as WebGLTexture;\n    gl.bindTexture(gl.TEXTURE_2D, tex);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n    gl.texImage2D(\n      gl.TEXTURE_2D,\n      0,\n      internal,\n      w,\n      h,\n      0,\n      format,\n      gl.HALF_FLOAT,\n      null\n    );\n    const fbo = gl.createFramebuffer() as WebGLFramebuffer;\n    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);\n    gl.framebufferTexture2D(\n      gl.FRAMEBUFFER,\n      gl.COLOR_ATTACHMENT0,\n      gl.TEXTURE_2D,\n      tex,\n      0\n    );\n    gl.viewport(0, 0, w, h);\n    gl.clearColor(0, 0, 0, 1);\n    gl.clear(gl.COLOR_BUFFER_BIT);\n    textures.push(tex);\n    framebuffers.push(fbo);\n    return { tex, fbo, w, h };\n  };\n\n  const double = (\n    w: number,\n    h: number,\n    internal: number,\n    format: number\n  ): Double => {\n    const pair = {\n      read: target(w, h, internal, format),\n      write: target(w, h, internal, format),\n      swap: () => {\n        const t = pair.read;\n        pair.read = pair.write;\n        pair.write = t;\n      },\n    };\n    return pair;\n  };\n\n  let velocity: Double;\n  let dye: Double;\n  let pressure: Double;\n  let divergence: Target;\n  let curl: Target;\n  let aspect = 1;\n  const mask = gl.createTexture() as WebGLTexture;\n  gl.bindTexture(gl.TEXTURE_2D, mask);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n  gl.texImage2D(\n    gl.TEXTURE_2D,\n    0,\n    gl.R8,\n    1,\n    1,\n    0,\n    gl.RED,\n    gl.UNSIGNED_BYTE,\n    new Uint8Array([0])\n  );\n\n  const free = () => {\n    for (const t of textures) {\n      gl.deleteTexture(t);\n    }\n    for (const f of framebuffers) {\n      gl.deleteFramebuffer(f);\n    }\n    textures.length = 0;\n    framebuffers.length = 0;\n  };\n\n  const sizeFor = (short: number) =>\n    aspect >= 1\n      ? [Math.round(short * aspect), short]\n      : [short, Math.round(short / aspect)];\n\n  // Binds a program, its textures and its output, and draws the quad.\n  let unit = 0;\n  const run = (name: ProgramName, texel: [number, number]) => {\n    const p = programs[name];\n    // biome-ignore lint/correctness/useHookAtTopLevel: WebGL's useProgram, not a React hook.\n    gl.useProgram(p.program);\n    gl.uniform2f(p.uniforms.texel, texel[0], texel[1]);\n    unit = 0;\n    return p;\n  };\n  const bind = (p: Program, uniform: string, tex: WebGLTexture) => {\n    gl.activeTexture(gl.TEXTURE0 + unit);\n    gl.bindTexture(gl.TEXTURE_2D, tex);\n    gl.uniform1i(p.uniforms[uniform], unit);\n    unit++;\n  };\n  const blit = (out: Target | null, w?: number, h?: number) => {\n    gl.bindFramebuffer(gl.FRAMEBUFFER, out ? out.fbo : null);\n    gl.viewport(0, 0, out ? out.w : (w ?? 1), out ? out.h : (h ?? 1));\n    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n  };\n\n  let paper = [1, 1, 1];\n  let inkColor = [0, 0, 0];\n  let accentColor = [0, 0, 0];\n  let canvasW = 1;\n  let canvasH = 1;\n\n  return {\n    resize(w, h) {\n      canvasW = w;\n      canvasH = h;\n      aspect = w / h;\n      free();\n      const [sw, sh] = sizeFor(SIM);\n      const [dw, dh] = sizeFor(DYE);\n      velocity = double(sw, sh, gl.RG16F, gl.RG);\n      pressure = double(sw, sh, gl.R16F, gl.RED);\n      divergence = target(sw, sh, gl.R16F, gl.RED);\n      curl = target(sw, sh, gl.R16F, gl.RED);\n      dye = double(dw, dh, gl.RGBA16F, gl.RGBA);\n    },\n    setMask(source) {\n      gl.bindTexture(gl.TEXTURE_2D, mask);\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, gl.RED, gl.UNSIGNED_BYTE, source);\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);\n    },\n    setColors(p, i, a) {\n      paper = p;\n      inkColor = i;\n      accentColor = a;\n    },\n    splat(x, y, vx, vy, ink, radius) {\n      const r = radius * (aspect > 1 ? aspect : 1);\n      let p = run(\"splat\", [1 / velocity.read.w, 1 / velocity.read.h]);\n      bind(p, \"uTarget\", velocity.read.tex);\n      gl.uniform1f(p.uniforms.aspect, aspect);\n      gl.uniform2f(p.uniforms.point, x, y);\n      gl.uniform3f(p.uniforms.color, vx, vy, 0);\n      gl.uniform1f(p.uniforms.radius, r);\n      blit(velocity.write);\n      velocity.swap();\n      if (ink[0] || ink[1]) {\n        p = run(\"splat\", [1 / dye.read.w, 1 / dye.read.h]);\n        bind(p, \"uTarget\", dye.read.tex);\n        gl.uniform1f(p.uniforms.aspect, aspect);\n        gl.uniform2f(p.uniforms.point, x, y);\n        gl.uniform3f(p.uniforms.color, ink[0], ink[1], 0);\n        gl.uniform1f(p.uniforms.radius, r);\n        blit(dye.write);\n        dye.swap();\n      }\n    },\n    step(dt, gather) {\n      const sim: [number, number] = [1 / velocity.read.w, 1 / velocity.read.h];\n      let p = run(\"curl\", sim);\n      bind(p, \"uVelocity\", velocity.read.tex);\n      blit(curl);\n\n      p = run(\"vorticity\", sim);\n      bind(p, \"uVelocity\", velocity.read.tex);\n      bind(p, \"uCurl\", curl.tex);\n      gl.uniform1f(p.uniforms.curl, CURL);\n      gl.uniform1f(p.uniforms.dt, dt);\n      blit(velocity.write);\n      velocity.swap();\n\n      p = run(\"divergence\", sim);\n      bind(p, \"uVelocity\", velocity.read.tex);\n      blit(divergence);\n\n      p = run(\"scale\", sim);\n      bind(p, \"uTarget\", pressure.read.tex);\n      gl.uniform1f(p.uniforms.value, 0.8);\n      blit(pressure.write);\n      pressure.swap();\n\n      for (let i = 0; i < PRESSURE_ITERATIONS; i++) {\n        p = run(\"pressure\", sim);\n        bind(p, \"uPressure\", pressure.read.tex);\n        bind(p, \"uDivergence\", divergence.tex);\n        blit(pressure.write);\n        pressure.swap();\n      }\n\n      p = run(\"gradient\", sim);\n      bind(p, \"uPressure\", pressure.read.tex);\n      bind(p, \"uVelocity\", velocity.read.tex);\n      blit(velocity.write);\n      velocity.swap();\n\n      p = run(\"advect\", sim);\n      bind(p, \"uVelocity\", velocity.read.tex);\n      bind(p, \"uSource\", velocity.read.tex);\n      gl.uniform2f(p.uniforms.simTexel, sim[0], sim[1]);\n      gl.uniform1f(p.uniforms.dt, dt);\n      gl.uniform1f(p.uniforms.fade, VELOCITY_FADE);\n      blit(velocity.write);\n      velocity.swap();\n\n      p = run(\"advect\", [1 / dye.read.w, 1 / dye.read.h]);\n      bind(p, \"uVelocity\", velocity.read.tex);\n      bind(p, \"uSource\", dye.read.tex);\n      gl.uniform2f(p.uniforms.simTexel, sim[0], sim[1]);\n      gl.uniform1f(p.uniforms.dt, dt);\n      gl.uniform1f(p.uniforms.fade, DYE_FADE);\n      blit(dye.write);\n      dye.swap();\n\n      if (gather > 0) {\n        // Calm the water as the letters gather, so they settle crisp.\n        p = run(\"scale\", sim);\n        bind(p, \"uTarget\", velocity.read.tex);\n        gl.uniform1f(p.uniforms.value, 1 - gather);\n        blit(velocity.write);\n        velocity.swap();\n\n        p = run(\"gather\", [1 / dye.read.w, 1 / dye.read.h]);\n        bind(p, \"uDye\", dye.read.tex);\n        bind(p, \"uMask\", mask);\n        gl.uniform1f(p.uniforms.rate, gather);\n        blit(dye.write);\n        dye.swap();\n      }\n    },\n    draw() {\n      const p = run(\"display\", [1 / dye.read.w, 1 / dye.read.h]);\n      bind(p, \"uDye\", dye.read.tex);\n      gl.uniform3fv(p.uniforms.paper, paper);\n      gl.uniform3fv(p.uniforms.ink, inkColor);\n      gl.uniform3fv(p.uniforms.accent, accentColor);\n      blit(null, canvasW, canvasH);\n    },\n    destroy() {\n      free();\n      gl.deleteTexture(mask);\n      gl.deleteBuffer(quad);\n      for (const p of Object.values(programs)) {\n        gl.deleteProgram(p.program);\n      }\n      gl.deleteShader(vertex);\n    },\n  };\n}\n\n/** Where the headline sits: centre line as a share of the height, and size. */\nfunction headline(width: number, height: number) {\n  const size = Math.max(44, Math.min(width * 0.13, height * 0.24, 140));\n  return { size, center: height * 0.4 };\n}\n\n/** Writes the headline into a canvas as soft white letters on black. */\nfunction drawMask(\n  canvas: HTMLCanvasElement,\n  text: string,\n  width: number,\n  height: number,\n  font: string\n) {\n  const scale = DYE / Math.min(width, height);\n  canvas.width = Math.round(width * scale);\n  canvas.height = Math.round(height * scale);\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    return;\n  }\n  const { size, center } = headline(width, height);\n  ctx.setTransform(scale, 0, 0, scale, 0, 0);\n  ctx.fillStyle = \"black\";\n  ctx.fillRect(0, 0, width, height);\n  ctx.font = `650 ${size}px ${font}`;\n  ctx.letterSpacing = `${-size * 0.045}px`;\n  ctx.textAlign = \"center\";\n  ctx.textBaseline = \"middle\";\n  // A little blur gives the letters a wet, feathered edge to bleed from.\n  ctx.filter = `blur(${Math.max(1, size * 0.012)}px)`;\n  ctx.fillStyle = \"white\";\n  ctx.fillText(text, width / 2, center, width - 32);\n}\n\nexport default function InkBackground({\n  text = \"Write freely.\",\n  accent,\n  children,\n  interactive = true,\n  className,\n}: InkBackgroundProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const paperRef = useRef<HTMLSpanElement>(null);\n  const inkRef = useRef<HTMLSpanElement>(null);\n  const [failed, setFailed] = useState(false);\n  const [contentTop, setContentTop] = useState<number | null>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const gl = canvas?.getContext(\"webgl2\", {\n      alpha: false,\n      antialias: false,\n      depth: false,\n      preserveDrawingBuffer: false,\n    });\n    const probe = document\n      .createElement(\"canvas\")\n      .getContext(\"2d\", { willReadFrequently: true });\n    if (!(root && canvas && probe)) {\n      return;\n    }\n    const fluid = gl ? createFluid(gl) : null;\n    if (!fluid) {\n      setFailed(true);\n      return;\n    }\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const maskCanvas = document.createElement(\"canvas\");\n\n    let width = 0;\n    let height = 0;\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let colorKey = \"\";\n    // Time of the last stir, and of the last thing that moved the fluid.\n    let touched = -Number.POSITIVE_INFINITY;\n    let active = performance.now();\n    const born = performance.now();\n    const hand = { x: 0, y: 0, on: false, down: false, moved: 0 };\n\n    const readColors = () => {\n      const paper = getComputedStyle(paperRef.current as Element).color;\n      const ink = getComputedStyle(inkRef.current as Element).color;\n      const key = `${paper}|${ink}|${accent}`;\n      if (key === colorKey) {\n        return false;\n      }\n      colorKey = key;\n      const inkRgb = toRgb(ink, probe);\n      fluid.setColors(\n        toRgb(paper, probe),\n        inkRgb,\n        accent ? toRgb(accent, probe) : inkRgb\n      );\n      return true;\n    };\n\n    const build = () => {\n      const rect = root.getBoundingClientRect();\n      width = rect.width;\n      height = rect.height;\n      if (width < 2 || height < 2) {\n        return;\n      }\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      fluid.resize(canvas.width, canvas.height);\n      drawMask(\n        maskCanvas,\n        text,\n        width,\n        height,\n        getComputedStyle(root).fontFamily\n      );\n      fluid.setMask(maskCanvas);\n      const { size, center } = headline(width, height);\n      setContentTop(center + size * 0.62);\n      // Reduced motion: the letters, already gathered, and no flow.\n      fluid.step(1 / 60, reduceMotion ? 1 : 0);\n      fluid.draw();\n    };\n\n    // Ink drops land around the letters and the headline pulls them in.\n    const bloom = () => {\n      const { size, center } = headline(width, height);\n      const y = 1 - center / height;\n      for (let i = 0; i < 9; i++) {\n        const x = 0.2 + (0.6 * (i + Math.random() * 0.6)) / 9;\n        const dy = ((Math.random() - 0.5) * size * 0.6) / height;\n        fluid.splat(\n          x,\n          y + dy,\n          (Math.random() - 0.5) * 320,\n          (Math.random() - 0.5) * 320,\n          [0.45, 0],\n          DROP_RADIUS * (0.6 + Math.random())\n        );\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      if (!visible) {\n        return;\n      }\n      const dt = Math.min((now - (last || now - 16)) / 1000, 1 / 30);\n      last = now;\n      if (hand.down || hand.moved > now - 100) {\n        active = now;\n      }\n      // Hold off while someone is stirring, then ease the letters back.\n      const idle = now - touched;\n      let gather = 0;\n      if (idle > GATHER_AFTER) {\n        gather = GATHER * Math.min(1, (idle - GATHER_AFTER) / 1500);\n      }\n      // The first second and a half is the bloom: the letters fill in fast.\n      const age = now - born;\n      if (age < 1500) {\n        gather = Math.max(gather, 0.012 + (age / 1500) * GATHER);\n      }\n      fluid.step(dt, gather);\n      fluid.draw();\n      if (now - active < SLEEP_AFTER || age < 4000) {\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    const start = () => {\n      if (!raf && visible && !reduceMotion) {\n        last = 0;\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    readColors();\n    build();\n    canvas.style.opacity = \"1\";\n    if (!reduceMotion) {\n      bloom();\n      start();\n    }\n\n    const resizeObserver = new ResizeObserver(() => {\n      const rect = root.getBoundingClientRect();\n      if (\n        Math.abs(rect.width - width) > 1 ||\n        Math.abs(rect.height - height) > 1\n      ) {\n        build();\n        active = performance.now();\n        touched = -Number.POSITIVE_INFINITY;\n        start();\n      }\n    });\n    resizeObserver.observe(root);\n\n    // A tab coming back must not wake a background that's scrolled away.\n    let onScreen = true;\n    const intersection = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting;\n      visible = onScreen && document.visibilityState === \"visible\";\n      start();\n    });\n    intersection.observe(root);\n    const onVisibility = () => {\n      visible = onScreen && document.visibilityState === \"visible\";\n      start();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    // Follow light and dark mode without asking the app for a theme. Only\n    // checked while the background is on screen.\n    const colorTimer = window.setInterval(() => {\n      if (!visible) {\n        return;\n      }\n      if (readColors() && !raf) {\n        fluid.draw();\n      }\n    }, 500);\n\n    const local = (event: PointerEvent) => {\n      const rect = root.getBoundingClientRect();\n      return {\n        x: (event.clientX - rect.left) / rect.width,\n        y: 1 - (event.clientY - rect.top) / rect.height,\n      };\n    };\n\n    const onMove = (event: PointerEvent) => {\n      const p = local(event);\n      if (hand.on) {\n        const dx = p.x - hand.x;\n        const dy = p.y - hand.y;\n        if (dx || dy) {\n          // Hovering stirs the water; dragging also lays down ink.\n          fluid.splat(\n            p.x,\n            p.y,\n            dx * STIR_FORCE,\n            dy * STIR_FORCE,\n            hand.down ? [0, 0.08] : [0, 0],\n            STIR_RADIUS\n          );\n          touched = performance.now();\n          hand.moved = touched;\n        }\n      }\n      hand.x = p.x;\n      hand.y = p.y;\n      hand.on = true;\n      start();\n    };\n    const onLeave = () => {\n      hand.on = false;\n      hand.down = false;\n    };\n    const onDown = (event: PointerEvent) => {\n      if ((event.target as HTMLElement).closest(\"a, button, input, textarea\")) {\n        return;\n      }\n      const p = local(event);\n      hand.down = true;\n      hand.on = true;\n      hand.x = p.x;\n      hand.y = p.y;\n      // A drop: a dab of ink pushed out in a ring.\n      fluid.splat(p.x, p.y, 0, 0, [0, 0.65], DROP_RADIUS);\n      for (let i = 0; i < 8; i++) {\n        const a = (i / 8) * Math.PI * 2;\n        const r = 0.012;\n        fluid.splat(\n          p.x + Math.cos(a) * r * (height / width),\n          p.y + Math.sin(a) * r,\n          Math.cos(a) * 420,\n          Math.sin(a) * 420,\n          [0, 0],\n          STIR_RADIUS * 0.6\n        );\n      }\n      touched = performance.now();\n      active = touched;\n      start();\n    };\n    const onUp = () => {\n      hand.down = false;\n    };\n    if (interactive && !reduceMotion) {\n      root.addEventListener(\"pointermove\", onMove);\n      root.addEventListener(\"pointerleave\", onLeave);\n      root.addEventListener(\"pointerdown\", onDown);\n      root.addEventListener(\"pointerup\", onUp);\n      root.addEventListener(\"pointercancel\", onUp);\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      resizeObserver.disconnect();\n      intersection.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      window.clearInterval(colorTimer);\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      root.removeEventListener(\"pointerdown\", onDown);\n      root.removeEventListener(\"pointerup\", onUp);\n      root.removeEventListener(\"pointercancel\", onUp);\n      fluid.destroy();\n    };\n  }, [text, accent, interactive]);\n\n  return (\n    <div\n      className={cn(\n        \"relative min-h-[560px] w-full touch-pan-y overflow-hidden bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100\",\n        className\n      )}\n      ref={rootRef}\n    >\n      {/* Theme colors for the shader: the paper and the ink. */}\n      <span className=\"hidden text-white dark:text-zinc-950\" ref={paperRef} />\n      <span className=\"hidden text-zinc-900 dark:text-zinc-100\" ref={inkRef} />\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n        <canvas\n          className=\"h-full w-full opacity-0 transition-opacity duration-500 ease-out\"\n          ref={canvasRef}\n        />\n      </div>\n      <h2\n        className={cn(\n          failed\n            ? \"absolute inset-x-0 top-[40%] -translate-y-1/2 text-balance px-6 text-center font-semibold text-5xl tracking-tight sm:text-7xl\"\n            : \"sr-only\"\n        )}\n      >\n        {text}\n      </h2>\n      <div\n        className=\"pointer-events-none absolute inset-x-0 flex justify-center px-6\"\n        style={{ top: contentTop ?? \"62%\" }}\n      >\n        {children === undefined ? <DefaultContent /> : children}\n      </div>\n    </div>\n  );\n}\n\nfunction DefaultContent() {\n  return (\n    <div className=\"flex max-w-md flex-col items-center gap-6 text-center\">\n      <p className=\"text-balance text-base text-zinc-500 leading-relaxed dark:text-zinc-400\">\n        A quiet page for first drafts. Nothing to set up, nothing to sync. Stir\n        the ink and it settles back.\n      </p>\n      <div className=\"pointer-events-auto flex items-center gap-2\">\n        <a\n          className=\"inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 font-medium text-sm text-white transition-colors duration-150 hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300\"\n          href=\"#start\"\n        >\n          Start writing\n        </a>\n        <a\n          className=\"inline-flex h-10 items-center rounded-full px-4 font-medium text-sm text-zinc-600 transition-colors duration-150 hover:bg-zinc-900/5 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-zinc-100\"\n          href=\"#tour\"\n        >\n          See how it works\n        </a>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/ink-background.tsx",
      "target": "components/kokonutui/ink-background.tsx"
    }
  ]
}