{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink-trail",
  "type": "registry:ui",
  "title": "Ink Trail",
  "description": "Pointer strokes leave ink that bleeds, drifts and fades like ink dropped in water, cycling through a palette.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/ink-trail.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type InkTrailPalette = \"accent\" | \"ink\" | \"tide\" | \"spectrum\";\n\nexport interface InkTrailProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** How quickly ink bleeds into its surroundings, 0–1. */\n  diffusion?: number;\n  /** How quickly ink clears, 0–1. 0 keeps ink for a long time. */\n  fade?: number;\n  /** Brush diameter, in px. */\n  brushSize?: number;\n  /** Colours the brush cycles through as you paint. `accent` uses accent → secondary; `ink` follows the theme. */\n  palette?: InkTrailPalette;\n  /** First colour of the accent palette (any CSS colour). */\n  accent?: string;\n  /** Second colour of the accent palette (any CSS colour). */\n  secondary?: string;\n  /** Let occasional drops fall on their own while nobody is painting. */\n  autoDrip?: boolean;\n}\n\ntype RGB = [number, number, number];\n\n/* ---------------------------------- Colour --------------------------------- */\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */\nfunction resolveColor(input: string, fallback: RGB): RGB {\n  probe ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!probe) return fallback;\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = \"rgba(0, 0, 0, 0)\";\n  probe.fillStyle = input; // invalid colours are ignored → transparent → fallback\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n  return a === 0 ? fallback : [r, g, b];\n}\n\nconst luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n\n/** On light themes, very pale colours are deepened (keeping their hue) so they still read as ink. */\nfunction legible(color: RGB, ink: RGB): RGB {\n  if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;\n  return color.map((v) => Math.round(v * 0.68)) as RGB;\n}\n\n/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */\nfunction observeTheme(el: Element, onChange: () => void) {\n  const mo = new MutationObserver(onChange);\n  const options = { attributes: true, attributeFilter: [\"class\", \"data-theme\"] };\n  mo.observe(document.documentElement, options);\n  const scoped = el.parentElement?.closest(\"[data-theme]\");\n  if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);\n  const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => {\n    mo.disconnect();\n    mq.removeEventListener(\"change\", onChange);\n  };\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Simulation ------------------------------- */\n\nconst STEP_MS = 1000 / 60;\nconst PALETTES: Record<Exclude<InkTrailPalette, \"accent\" | \"ink\">, string[]> = {\n  tide: [\"#1f6feb\", \"#1f9e89\", \"#5fb8e8\", \"#2b4c7e\"],\n  spectrum: [\"#ff7448\", \"#f0b429\", \"#1f9e89\", \"#3d8bea\", \"#e87ba4\"],\n};\n\n/** Small deterministic PRNG, so the opening composition is the same on every visit. */\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/**\n * The ink lives on a coarse grid (about 15k cells): density plus colour-weighted density per\n * channel, and a velocity field. Each fixed 60Hz step advects the ink along the velocity\n * (semi-Lagrangian), blurs it a little (diffusion) and fades it. The grid is written into a\n * small ImageData and upscaled with smoothing, which is what makes the edges bleed softly.\n */\ninterface Field {\n  w: number;\n  h: number;\n  /** Density and colour-weighted density (r, g, b), plus scratch buffers for each step. */\n  d: Float32Array;\n  r: Float32Array;\n  g: Float32Array;\n  b: Float32Array;\n  vx: Float32Array;\n  vy: Float32Array;\n  t0: Float32Array;\n  t1: Float32Array;\n  t2: Float32Array;\n  t3: Float32Array;\n}\n\nfunction createField(w: number, h: number): Field {\n  const n = w * h;\n  const f = () => new Float32Array(n);\n  return { w, h, d: f(), r: f(), g: f(), b: f(), vx: f(), vy: f(), t0: f(), t1: f(), t2: f(), t3: f() };\n}\n\n/**\n * Ink that bleeds and drifts like ink dropped in water. Pointer strokes deposit colour and push\n * the water along with them; the ink then diffuses, swirls on a slow ambient current and fades.\n * Colours cycle through a palette as you paint. Touch paints while the finger is down, with\n * passive listeners, so scrolling is never blocked.\n */\nexport function InkTrail({\n  diffusion = 0.5,\n  fade = 0.4,\n  brushSize = 44,\n  palette = \"accent\",\n  accent = \"#FF7448\",\n  secondary = \"#B6F3D1\",\n  autoDrip = true,\n  className = \"\",\n  children,\n  ...props\n}: InkTrailProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    const small = document.createElement(\"canvas\");\n    const sctx = small.getContext(\"2d\");\n    if (!canvas || !ctx || !sctx) return;\n\n    const blur = 0.004 + Math.min(1, Math.max(0, diffusion)) * 0.07; // explicit diffusion coefficient (≤ 0.25 is stable)\n    const keep = 1 - (0.0015 + Math.min(1, Math.max(0, fade)) * 0.012); // density kept per step\n    const brush = Math.max(6, brushSize) / 2;\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let cell = 6;\n    let field: Field | null = null;\n    let image: ImageData | null = null;\n    let colors: RGB[] = [];\n    let hue = 0; // position along the palette\n    let raf = 0;\n    let last = 0;\n    let pending = 0;\n    let active = false;\n    let inView = false;\n    let clock = 0; // simulation time in steps\n    let idleSince = 0;\n    let nextDrip = 0;\n    const random = mulberry32(1337);\n    const ptr = { x: 0, y: 0, px: 0, py: 0, t: 0, down: false, has: false };\n\n    const readTheme = () => {\n      const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      const list =\n        palette === \"ink\"\n          ? [ink]\n          : palette === \"accent\"\n            ? [resolveColor(accent, [255, 116, 72]), resolveColor(secondary, [182, 243, 209])]\n            : PALETTES[palette].map((c) => resolveColor(c, [255, 116, 72]));\n      colors = list.map((c) => legible(c, ink));\n    };\n\n    /** Current brush colour (0–1 floats), blended smoothly between palette entries. */\n    const brushColor = (): RGB => {\n      const n = colors.length;\n      if (n === 1) return colors[0].map((v) => v / 255) as RGB;\n      const i = Math.floor(hue) % n;\n      const j = (i + 1) % n;\n      const t = hue - Math.floor(hue);\n      const s = t * t * (3 - 2 * t);\n      return colors[i].map((v, k) => (v + (colors[j][k] - v) * s) / 255) as RGB;\n    };\n\n    /** Deposits ink (and momentum) in a soft disc around (x, y), given in px. */\n    const stamp = (x: number, y: number, radius: number, amount: number, vx: number, vy: number) => {\n      const f = field;\n      if (!f) return;\n      const [cr, cg, cb] = brushColor();\n      const gx = x / cell;\n      const gy = y / cell;\n      const rr = Math.max(1, radius / cell);\n      const x0 = Math.max(0, Math.floor(gx - rr));\n      const x1 = Math.min(f.w - 1, Math.ceil(gx + rr));\n      const y0 = Math.max(0, Math.floor(gy - rr));\n      const y1 = Math.min(f.h - 1, Math.ceil(gy + rr));\n      for (let j = y0; j <= y1; j++) {\n        for (let i = x0; i <= x1; i++) {\n          const dx = i - gx;\n          const dy = j - gy;\n          const q = 1 - (dx * dx + dy * dy) / (rr * rr);\n          if (q <= 0) continue;\n          const w = q * q;\n          const k = j * f.w + i;\n          const a = w * amount;\n          f.d[k] += a;\n          f.r[k] += a * cr;\n          f.g[k] += a * cg;\n          f.b[k] += a * cb;\n          f.vx[k] += (vx - f.vx[k]) * w * 0.5;\n          f.vy[k] += (vy - f.vy[k]) * w * 0.5;\n        }\n      }\n      active = true;\n    };\n\n    /** A drop: a dense blob whose water pushes outward, so it blooms. */\n    const drop = (x: number, y: number, radius: number) => {\n      const f = field;\n      if (!f) return;\n      stamp(x, y, radius, 0.9, 0, 0);\n      const gx = x / cell;\n      const gy = y / cell;\n      const rr = (radius * 1.6) / cell;\n      for (let j = Math.max(0, Math.floor(gy - rr)); j <= Math.min(f.h - 1, Math.ceil(gy + rr)); j++) {\n        for (let i = Math.max(0, Math.floor(gx - rr)); i <= Math.min(f.w - 1, Math.ceil(gx + rr)); i++) {\n          const dx = i - gx;\n          const dy = j - gy;\n          const d = Math.hypot(dx, dy);\n          if (d < 0.5 || d > rr) continue;\n          const push = (1 - d / rr) * 0.9;\n          f.vx[j * f.w + i] += (dx / d) * push;\n          f.vy[j * f.w + i] += (dy / d) * push;\n        }\n      }\n      hue += 0.37;\n    };\n\n    /** Bilinear sample of `src` at grid coordinates (x, y), clamped to the edges. */\n    const sample = (src: Float32Array, w: number, h: number, x: number, y: number) => {\n      x = Math.min(w - 1.001, Math.max(0, x));\n      y = Math.min(h - 1.001, Math.max(0, y));\n      const i = x | 0;\n      const j = y | 0;\n      const fx = x - i;\n      const fy = y - j;\n      const k = j * w + i;\n      const a = src[k] + (src[k + 1] - src[k]) * fx;\n      const b = src[k + w] + (src[k + w + 1] - src[k + w]) * fx;\n      return a + (b - a) * fy;\n    };\n\n    /** One fixed simulation step. Returns true while visible ink remains. */\n    const step = () => {\n      const f = field;\n      if (!f) return false;\n      const { w, h } = f;\n      clock++;\n\n      // 1. Velocity: decay plus a slow ambient current (a drifting sine field, ~0.08 cells per step at rest).\n      // Two octaves per axis give the drifting ink its tendrils.\n      const tt = clock * 0.004;\n      for (let j = 0; j < h; j++) {\n        const ax = (Math.sin(j * 0.13 + tt) + Math.sin(j * 0.37 - tt * 1.7) * 0.6) * 0.004;\n        for (let i = 0; i < w; i++) {\n          const k = j * w + i;\n          const ay = (Math.cos(i * 0.11 - tt * 1.3) + Math.cos(i * 0.31 + tt * 1.1) * 0.6) * 0.004;\n          f.vx[k] = f.vx[k] * 0.95 + ax;\n          f.vy[k] = f.vy[k] * 0.95 + ay;\n        }\n      }\n\n      // 2. Advect density and colour backwards along the velocity.\n      const { d, r, g, b, vx, vy, t0, t1, t2, t3 } = f;\n      for (let j = 0; j < h; j++) {\n        for (let i = 0; i < w; i++) {\n          const k = j * w + i;\n          const sx = i - vx[k];\n          const sy = j - vy[k];\n          t0[k] = sample(d, w, h, sx, sy);\n          t1[k] = sample(r, w, h, sx, sy);\n          t2[k] = sample(g, w, h, sx, sy);\n          t3[k] = sample(b, w, h, sx, sy);\n        }\n      }\n\n      // 3. Diffuse (one explicit 5-point pass) and fade, writing back into the main buffers.\n      let any = false;\n      const bufs: [Float32Array, Float32Array][] = [\n        [t0, d],\n        [t1, r],\n        [t2, g],\n        [t3, b],\n      ];\n      for (const [src, dst] of bufs) {\n        for (let j = 0; j < h; j++) {\n          const up = j > 0 ? -w : 0;\n          const down = j < h - 1 ? w : 0;\n          for (let i = 0; i < w; i++) {\n            const k = j * w + i;\n            const c = src[k];\n            const left = i > 0 ? src[k - 1] : c;\n            const right = i < w - 1 ? src[k + 1] : c;\n            const lap = left + right + src[k + up] + src[k + down] - 4 * c;\n            const v = (c + blur * lap) * keep;\n            dst[k] = v < 1e-4 ? 0 : v;\n          }\n        }\n      }\n      for (let k = 0; k < d.length; k++) {\n        if (d[k] > 0.004) {\n          any = true;\n          break;\n        }\n      }\n      return any;\n    };\n\n    /** Writes the field into the small canvas and upscales it (with smoothing) onto the main one. */\n    const render = () => {\n      const f = field;\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      if (!f || !image) return;\n      const px = image.data;\n      for (let k = 0; k < f.d.length; k++) {\n        const dens = f.d[k];\n        const o = k * 4;\n        if (dens < 0.002) {\n          px[o + 3] = 0;\n          continue;\n        }\n        const inv = 255 / dens;\n        px[o] = f.r[k] * inv;\n        px[o + 1] = f.g[k] * inv;\n        px[o + 2] = f.b[k] * inv;\n        // Steep tone curve: thin ink vanishes and body ink reads solid, so edges stay crisp\n        // while the inside keeps its density variation.\n        const a = (1 - Math.exp(-dens * 5) - 0.12) / 0.62;\n        px[o + 3] = a <= 0 ? 0 : Math.min(1, a) * 235;\n      }\n      sctx.putImageData(image, 0, 0);\n      ctx.imageSmoothingEnabled = true;\n      ctx.imageSmoothingQuality = \"high\";\n      ctx.drawImage(small, 0, 0, f.w * cell * dpr, f.h * cell * dpr);\n    };\n\n    /** The opening composition: a few seeded drops and one stroke, so the field is never blank. */\n    const seed = () => {\n      for (let n = 0; n < 4; n++) {\n        drop(width * (0.18 + random() * 0.64), height * (0.22 + random() * 0.56), brush * (1 + random() * 0.8));\n      }\n      let x = width * 0.12;\n      let y = height * 0.7;\n      for (let s = 0; s < 40; s++) {\n        const nx = x + width * 0.019;\n        const ny = height * (0.62 - Math.sin(s / 7) * 0.22);\n        stamp(nx, ny, brush * 0.8, 0.32, (nx - x) / cell / 2, (ny - y) / cell / 2);\n        hue += 0.02;\n        x = nx;\n        y = ny;\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : STEP_MS);\n      last = now;\n\n      // Autonomous drops while nobody is painting.\n      if (autoDrip && now - idleSince > 2400 && now > nextDrip) {\n        drop(width * (0.12 + random() * 0.76), height * (0.15 + random() * 0.7), brush * (0.8 + random() * 0.9));\n        nextDrip = now + 1100 + random() * 900;\n      }\n\n      // Fixed 60Hz steps; a short (120/144Hz) frame may run none and keeps the previous verdict.\n      pending = Math.min(pending + dt, STEP_MS * 3);\n      if (pending >= STEP_MS) {\n        let still = false;\n        while (pending >= STEP_MS) {\n          still = step() || still;\n          pending -= STEP_MS;\n        }\n        active = still;\n      }\n      render();\n      if (active || autoDrip) schedule();\n      else last = 0;\n    };\n\n    const schedule = () => {\n      if (!raf && inView && !document.hidden && !reduceMotion) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    // Reduced motion: no loop. Strokes still leave ink, settled with a few instant steps, and redrawn once.\n    let stillRaf = 0;\n    const settleNow = (steps: number) => {\n      for (let s = 0; s < steps; s++) step();\n      if (!stillRaf) {\n        stillRaf = requestAnimationFrame(() => {\n          stillRaf = 0;\n          render();\n        });\n      }\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && field) return;\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      if (width < 1 || height < 1) return;\n      // ~15k cells regardless of size, never finer than 4px or coarser than 12px.\n      cell = Math.min(12, Math.max(4, Math.sqrt((width * height) / 15000)));\n      const fresh = !field;\n      field = createField(Math.ceil(width / cell) + 1, Math.ceil(height / cell) + 1);\n      small.width = field.w;\n      small.height = field.h;\n      image = sctx.createImageData(field.w, field.h);\n      if (fresh || reduceMotion) {\n        seed();\n        if (reduceMotion) for (let s = 0; s < 70; s++) step();\n      }\n      render();\n      schedule();\n    };\n\n    /* --------------------------------- Input -------------------------------- */\n\n    const toLocal = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      const x = e.clientX - rect.left;\n      const y = e.clientY - rect.top;\n      return { x, y, inside: x >= 0 && y >= 0 && x <= rect.width && y <= rect.height };\n    };\n\n    /** Stamps along the segment from the previous pointer position, so fast strokes stay continuous. */\n    const paintTo = (x: number, y: number, now: number) => {\n      if (!ptr.has) {\n        ptr.px = x;\n        ptr.py = y;\n        ptr.t = now;\n        ptr.has = true;\n      }\n      const dx = x - ptr.px;\n      const dy = y - ptr.py;\n      const dist = Math.hypot(dx, dy);\n      const dt = Math.max(8, now - ptr.t);\n      // Pointer velocity in grid cells per step, so strokes push the water along.\n      const scale = STEP_MS / dt / cell;\n      const vx = Math.max(-3, Math.min(3, dx * scale));\n      const vy = Math.max(-3, Math.min(3, dy * scale));\n      const spacing = Math.max(2, brush * 0.35);\n      const n = Math.max(1, Math.ceil(dist / spacing));\n      for (let s = 1; s <= n; s++) {\n        const t = s / n;\n        stamp(ptr.px + dx * t, ptr.py + dy * t, brush, 0.3, vx, vy);\n      }\n      hue += dist / 420;\n      ptr.px = x;\n      ptr.py = y;\n      ptr.t = now;\n      idleSince = now;\n      if (reduceMotion) settleNow(2);\n      else schedule();\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && !ptr.down) return;\n      const p = toLocal(e);\n      if (!p.inside) {\n        ptr.has = false;\n        return;\n      }\n      paintTo(p.x, p.y, e.timeStamp || performance.now());\n    };\n    const onPointerDown = (e: PointerEvent) => {\n      const p = toLocal(e);\n      if (!p.inside) return;\n      ptr.down = true;\n      ptr.has = false;\n      paintTo(p.x, p.y, e.timeStamp || performance.now());\n    };\n    const onPointerUp = () => {\n      ptr.down = false;\n      ptr.has = false;\n    };\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n    const onThemeChange = () => {\n      readTheme();\n      render();\n    };\n\n    const passive = { passive: true } as const;\n    const listen = (on: boolean) => {\n      if (on) {\n        window.addEventListener(\"pointermove\", onPointerMove, passive);\n        window.addEventListener(\"pointerdown\", onPointerDown, passive);\n        window.addEventListener(\"pointerup\", onPointerUp, passive);\n        window.addEventListener(\"pointercancel\", onPointerUp, passive);\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        window.removeEventListener(\"pointerdown\", onPointerDown);\n        window.removeEventListener(\"pointerup\", onPointerUp);\n        window.removeEventListener(\"pointercancel\", onPointerUp);\n      }\n    };\n\n    // Listening on the window (only while visible) keeps content layered above the canvas interactive.\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) {\n        listen(true);\n        idleSince = performance.now() - 1800;\n        schedule();\n      } else {\n        listen(false);\n        ptr.has = false;\n        stop();\n      }\n    });\n\n    readTheme();\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    io.observe(canvas);\n    const unobserveTheme = observeTheme(canvas, onThemeChange);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      stop();\n      cancelAnimationFrame(stillRaf);\n      ro.disconnect();\n      io.disconnect();\n      unobserveTheme();\n      listen(false);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [diffusion, fade, brushSize, palette, accent, secondary, autoDrip, reduceMotion]);\n\n  return (\n    <div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 mix-blend-multiply dark:text-zinc-100 dark:mix-blend-screen\"\n      />\n      {children != null && <div className=\"relative h-full w-full\">{children}</div>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/ink-trail"
}
