shadimage
23rd
uiascii-fluidsource: ok · hit

ASCII Fluid

Mouse-trail WebGL fluid background quantized to a clean ASCII brightness ramp - zero deps, theme-aware.

npx shadcn add "https://23rd.dev/r/ascii-fluid.json"
free

Visual style

minimal

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/14/2026, 2:40:38 PM

registry/ascii-fluid/ascii-fluid.tsx
"use client"

import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"

export type AsciiFluidTheme = "light" | "dark" | "auto"

export type AsciiFluidOptions = {
  /**
   * Brightness ramp (sparse → dense). Default is a full letter/symbol map.
   */
  charset?: string
  /** Glyph cell size in CSS pixels. Default `12` */
  cellSize?: number
  /** Ink color (hex). Default follows theme. */
  color?: string
  /** Stage color (hex). Default follows theme. */
  backgroundColor?: string
  /** Mouse trail / fluid force. Default `1` */
  force?: number
  /** How quickly trails fade (higher = vanish sooner). Default `0.05` */
  dissipation?: number
  /** Trail brush size 0–1. Default `0.55` */
  brush?: number
  /** Soft ambient swirl when idle. Default `true` */
  animate?: boolean
  /** Follow pointer. Default `true` */
  interactive?: boolean
  /**
   * Palette mode. Default `auto` follows shadcn / next-themes
   * (`html.dark` class).
   */
  theme?: AsciiFluidTheme
}

export type AsciiFluidInstance = {
  setOptions: (options: Partial<AsciiFluidOptions>) => void
  destroy: () => void
}

/** Sparse → dense brightness ramp (letters + symbols). */
export const DEFAULT_CHARSET =
  " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"

const LIGHT = { ink: "#18181b", paper: "#fafafa" }
const DARK = { ink: "#e4e4e7", paper: "#09090b" }

const VERT = `
attribute vec2 a_position;
varying vec2 v_uv;
void main() {
  v_uv = a_position * 0.5 + 0.5;
  gl_Position = vec4(a_position, 0.0, 1.0);
}
`

// Velocity / dye stored biased in RGBA8: enc(v) = v * 0.05 + 0.5
const FRAG_SPLAT = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_target;
uniform vec2 u_point;
uniform vec3 u_color;
uniform float u_radius;
uniform float u_aspect;
uniform float u_velocityField;

vec2 dec(vec2 e) { return (e - 0.5) / 0.05; }
vec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }

void main() {
  vec2 p = v_uv - u_point;
  p.x *= u_aspect;
  float d = exp(-dot(p, p) / max(u_radius, 0.0001));
  vec3 base = texture2D(u_target, v_uv).xyz;
  if (u_velocityField > 0.5) {
    vec2 next = dec(base.xy) + u_color.xy * d;
    gl_FragColor = vec4(enc(next), 0.5, 1.0);
  } else {
    gl_FragColor = vec4(base + u_color * d, 1.0);
  }
}
`

const FRAG_ADVECT = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_velocity;
uniform sampler2D u_source;
uniform vec2 u_texel;
uniform float u_dt;
uniform float u_dissipation;
uniform float u_velocityField;

vec2 dec(vec2 e) { return (e - 0.5) / 0.05; }
vec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }

void main() {
  vec2 vel = dec(texture2D(u_velocity, v_uv).xy);
  vec2 coord = v_uv - u_dt * vel * u_texel * 110.0;
  vec4 src = texture2D(u_source, clamp(coord, 0.0, 1.0));
  if (u_velocityField > 0.5) {
    vec2 next = dec(src.xy) * u_dissipation;
    gl_FragColor = vec4(enc(next), 0.5, 1.0);
  } else {
    gl_FragColor = vec4(src.xyz * u_dissipation, 1.0);
  }
}
`

const FRAG_DIVERGENCE = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_velocity;
uniform vec2 u_texel;

vec2 dec(vec2 e) { return (e - 0.5) / 0.05; }

void main() {
  float L = dec(texture2D(u_velocity, v_uv - vec2(u_texel.x, 0.0)).xy).x;
  float R = dec(texture2D(u_velocity, v_uv + vec2(u_texel.x, 0.0)).xy).x;
  float B = dec(texture2D(u_velocity, v_uv - vec2(0.0, u_texel.y)).xy).y;
  float T = dec(texture2D(u_velocity, v_uv + vec2(0.0, u_texel.y)).xy).y;
  float div = 0.5 * ((R - L) + (T - B));
  gl_FragColor = vec4(div * 0.05 + 0.5, 0.0, 0.0, 1.0);
}
`

const FRAG_PRESSURE = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_pressure;
uniform sampler2D u_divergence;
uniform vec2 u_texel;

float dec1(float e) { return (e - 0.5) / 0.05; }
float enc1(float v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }

void main() {
  float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);
  float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);
  float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);
  float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);
  float C = dec1(texture2D(u_divergence, v_uv).x);
  float p = (L + R + B + T - C) * 0.25;
  gl_FragColor = vec4(enc1(p), 0.0, 0.0, 1.0);
}
`

const FRAG_GRADIENT = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_pressure;
uniform sampler2D u_velocity;
uniform vec2 u_texel;

float dec1(float e) { return (e - 0.5) / 0.05; }
vec2 dec(vec2 e) { return (e - 0.5) / 0.05; }
vec2 enc(vec2 v) { return clamp(v * 0.05 + 0.5, 0.0, 1.0); }

void main() {
  float L = dec1(texture2D(u_pressure, v_uv - vec2(u_texel.x, 0.0)).x);
  float R = dec1(texture2D(u_pressure, v_uv + vec2(u_texel.x, 0.0)).x);
  float B = dec1(texture2D(u_pressure, v_uv - vec2(0.0, u_texel.y)).x);
  float T = dec1(texture2D(u_pressure, v_uv + vec2(0.0, u_texel.y)).x);
  vec2 vel = dec(texture2D(u_velocity, v_uv).xy);
  vel -= vec2(R - L, T - B) * 0.5;
  gl_FragColor = vec4(enc(vel), 0.5, 1.0);
}
`

const FRAG_DISPLAY = `
precision highp float;
varying vec2 v_uv;
uniform sampler2D u_dye;
uniform sampler2D u_atlas;
uniform vec2 u_resolution;
uniform vec2 u_cell;
uniform float u_charCount;
uniform vec3 u_ink;
uniform vec3 u_paper;
uniform float u_time;
uniform float u_animate;

float hash21(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

void main() {
  vec2 pixel = v_uv * u_resolution;
  vec2 cell = floor(pixel / u_cell);
  vec2 cellUv = (cell + 0.5) * u_cell / u_resolution;

  float dens = clamp(texture2D(u_dye, cellUv).x, 0.0, 1.0);

  // Soft neighborhood sample for a low-res glow halo under the glyphs
  vec2 texel = u_cell / u_resolution;
  float glow =
    dens * 0.40 +
    texture2D(u_dye, cellUv + vec2( texel.x, 0.0)).x * 0.15 +
    texture2D(u_dye, cellUv - vec2( texel.x, 0.0)).x * 0.15 +
    texture2D(u_dye, cellUv + vec2(0.0,  texel.y)).x * 0.15 +
    texture2D(u_dye, cellUv - vec2(0.0,  texel.y)).x * 0.15;
  glow = clamp(glow, 0.0, 1.0);
  glow = pow(glow, 1.35);

  float lit = dens;
  if (u_animate > 0.5) {
    float flicker = hash21(cell + floor(u_time * 10.0)) - 0.5;
    lit = clamp(lit + flicker * 0.05, 0.0, 1.0);
  }

  float idx = min(floor(lit * (u_charCount - 0.001)), u_charCount - 1.0);
  vec2 local = fract(pixel / u_cell);
  float u0 = (idx + local.x) / u_charCount;
  float glyph = texture2D(u_atlas, vec2(u0, local.y)).r;

  float alpha = glyph * smoothstep(0.02, 0.12, dens);

  // Paper → soft ink wash → sharp ASCII on top
  float wash = glow * 0.22;
  vec3 col = mix(u_paper, u_ink, wash);
  col = mix(col, u_ink, clamp(alpha, 0.0, 1.0));
  gl_FragColor = vec4(col, 1.0);
}
`

function isDev() {
  return (
    typeof process !== "undefined" && process.env?.NODE_ENV !== "production"
  )
}

function hexToRgb(hex: string): [number, number, number] {
  const h = hex.replace("#", "").trim()
  const full =
    h.length === 3
      ? h
          .split("")
          .map((c) => c + c)
          .join("")
      : h.padEnd(6, "0").slice(0, 6)
  const n = Number.parseInt(full, 16)
  if (Number.isNaN(n)) return [0.1, 0.1, 0.12]
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]
}

function isDarkTheme(): boolean {
  if (typeof document === "undefined") return false
  const root = document.documentElement
  if (root.classList.contains("dark")) return true
  if (root.classList.contains("light")) return false
  const dataTheme = root.getAttribute("data-theme")
  if (dataTheme === "dark") return true
  if (dataTheme === "light") return false
  return window.matchMedia("(prefers-color-scheme: dark)").matches
}

function resolveDark(theme: AsciiFluidTheme): boolean {
  if (theme === "dark") return true
  if (theme === "light") return false
  return isDarkTheme()
}

function compile(gl: WebGLRenderingContext, type: number, source: string) {
  const shader = gl.createShader(type)
  if (!shader) return null
  gl.shaderSource(shader, source)
  gl.compileShader(shader)
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    if (isDev()) {
      console.warn(
        "AsciiFluid: shader failed to compile\n",
        gl.getShaderInfoLog(shader)
      )
    }
    gl.deleteShader(shader)
    return null
  }
  return shader
}

function createProgram(
  gl: WebGLRenderingContext,
  vs: WebGLShader,
  fragSource: string
) {
  const fs = compile(gl, gl.FRAGMENT_SHADER, fragSource)
  if (!fs) return null
  const program = gl.createProgram()
  if (!program) {
    gl.deleteShader(fs)
    return null
  }
  gl.attachShader(program, vs)
  gl.attachShader(program, fs)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    if (isDev()) {
      console.warn(
        "AsciiFluid: program failed to link\n",
        gl.getProgramInfoLog(program)
      )
    }
    gl.deleteProgram(program)
    gl.deleteShader(fs)
    return null
  }
  return { program, fs }
}

type FBO = {
  tex: WebGLTexture
  fbo: WebGLFramebuffer
  w: number
  h: number
}

function createFBO(
  gl: WebGLRenderingContext,
  w: number,
  h: number,
  filter: number
): FBO | null {
  const tex = gl.createTexture()
  const fbo = gl.createFramebuffer()
  if (!tex || !fbo) return null
  gl.bindTexture(gl.TEXTURE_2D, tex)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA,
    w,
    h,
    0,
    gl.RGBA,
    gl.UNSIGNED_BYTE,
    null
  )
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
  gl.framebufferTexture2D(
    gl.FRAMEBUFFER,
    gl.COLOR_ATTACHMENT0,
    gl.TEXTURE_2D,
    tex,
    0
  )
  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
  return { tex, fbo, w, h }
}

function createDoubleFBO(
  gl: WebGLRenderingContext,
  w: number,
  h: number,
  filter: number
) {
  const a = createFBO(gl, w, h, filter)
  const b = createFBO(gl, w, h, filter)
  if (!a || !b) return null
  return {
    read: a,
    write: b,
    swap() {
      const t = this.read
      this.read = this.write
      this.write = t
    },
  }
}

function buildAtlas(
  gl: WebGLRenderingContext,
  charset: string
): { tex: WebGLTexture; count: number } | null {
  const count = Math.max(charset.length, 1)
  const size = 64
  const atlasCanvas = document.createElement("canvas")
  atlasCanvas.width = size * count
  atlasCanvas.height = size
  const ctx = atlasCanvas.getContext("2d")
  if (!ctx) return null
  ctx.fillStyle = "#000"
  ctx.fillRect(0, 0, atlasCanvas.width, atlasCanvas.height)
  ctx.fillStyle = "#fff"
  ctx.font = `700 ${Math.floor(size * 0.72)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`
  ctx.textAlign = "center"
  ctx.textBaseline = "middle"
  for (let i = 0; i < count; i++) {
    const ch = charset[i] ?? " "
    if (ch === " ") continue
    ctx.fillText(ch, size * (i + 0.5), size * 0.55)
  }

  const tex = gl.createTexture()
  if (!tex) return null
  gl.bindTexture(gl.TEXTURE_2D, tex)
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA,
    gl.RGBA,
    gl.UNSIGNED_BYTE,
    atlasCanvas
  )
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)
  return { tex, count }
}

/**
 * ASCII fluid background: pointer trails leave ink that swirls and
 * quantizes to a clean brightness-mapped glyph field. Zero deps.
 *
 * The simulation stops while the canvas is off screen or the tab is hidden.
 * Under `prefers-reduced-motion` it holds a still frame and only redraws
 * when its size, theme or options change. It recovers from a lost WebGL
 * context.
 */
export function createAsciiFluid(
  canvas: HTMLCanvasElement,
  initial: AsciiFluidOptions = {}
): AsciiFluidInstance | null {
  let options: Required<
    Pick<
      AsciiFluidOptions,
      | "charset"
      | "cellSize"
      | "force"
      | "dissipation"
      | "brush"
      | "animate"
      | "interactive"
      | "theme"
    >
  > &
    AsciiFluidOptions = {
    charset: DEFAULT_CHARSET,
    cellSize: 12,
    force: 1,
    dissipation: 0.05,
    brush: 0.55,
    animate: true,
    interactive: true,
    theme: "auto",
    ...initial,
  }

  const mouse = {
    x: 0.5,
    y: 0.5,
    dx: 0,
    dy: 0,
    moved: false,
    inside: false,
  }
  let reduce = false
  let currentCharset = ""

  const gl = canvas.getContext("webgl", {
    alpha: false,
    antialias: false,
    depth: false,
    stencil: false,
    powerPreference: "high-performance",
    preserveDrawingBuffer: false,
  })
  if (!gl) return null

  type Program = NonNullable<ReturnType<typeof createProgram>>
  type DoubleFBO = NonNullable<ReturnType<typeof createDoubleFBO>>

  // GL resources live in `let`s so a restored context can rebuild them.
  let vs: WebGLShader
  let splat: Program
  let advect: Program
  let divergence: Program
  let pressure: Program
  let gradient: Program
  let display: Program
  let buf: WebGLBuffer | null
  let velocity: DoubleFBO
  let dye: DoubleFBO
  let pressureFbo: DoubleFBO
  let divergenceFbo: FBO
  let atlas: { tex: WebGLTexture; count: number }

  const bindQuad = (program: WebGLProgram) => {
    gl.useProgram(program)
    const loc = gl.getAttribLocation(program, "a_position")
    gl.enableVertexAttribArray(loc)
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
  }

  const SIM = 180

  const blit = (target: FBO | null) => {
    if (target) {
      gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo)
      gl.viewport(0, 0, target.w, target.h)
    } else {
      gl.bindFramebuffer(gl.FRAMEBUFFER, null)
      gl.viewport(0, 0, canvas.width, canvas.height)
    }
    gl.drawArrays(gl.TRIANGLES, 0, 6)
  }

  const clearFbo = (fbo: FBO, r = 0, g = 0, b = 0) => {
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo.fbo)
    gl.viewport(0, 0, fbo.w, fbo.h)
    gl.clearColor(r, g, b, 1)
    gl.clear(gl.COLOR_BUFFER_BIT)
  }

  // Builds every GL resource with an empty fluid. Runs at startup and again
  // when a lost context is restored; returns false if anything fails.
  const initGl = () => {
    const nextVs = compile(gl, gl.VERTEX_SHADER, VERT)
    if (!nextVs) return false

    const nextSplat = createProgram(gl, nextVs, FRAG_SPLAT)
    const nextAdvect = createProgram(gl, nextVs, FRAG_ADVECT)
    const nextDivergence = createProgram(gl, nextVs, FRAG_DIVERGENCE)
    const nextPressure = createProgram(gl, nextVs, FRAG_PRESSURE)
    const nextGradient = createProgram(gl, nextVs, FRAG_GRADIENT)
    const nextDisplay = createProgram(gl, nextVs, FRAG_DISPLAY)
    if (
      !nextSplat ||
      !nextAdvect ||
      !nextDivergence ||
      !nextPressure ||
      !nextGradient ||
      !nextDisplay
    ) {
      return false
    }

    const nextBuf = gl.createBuffer()
    gl.bindBuffer(gl.ARRAY_BUFFER, nextBuf)
    gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
      gl.STATIC_DRAW
    )

    const nextVelocity = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)
    const nextDye = createDoubleFBO(gl, SIM, SIM, gl.LINEAR)
    const nextPressureFbo = createDoubleFBO(gl, SIM, SIM, gl.NEAREST)
    const nextDivergenceFbo = createFBO(gl, SIM, SIM, gl.NEAREST)
    if (!nextVelocity || !nextDye || !nextPressureFbo || !nextDivergenceFbo) {
      return false
    }

    const nextAtlas = buildAtlas(gl, options.charset)
    if (!nextAtlas) return false

    vs = nextVs
    splat = nextSplat
    advect = nextAdvect
    divergence = nextDivergence
    pressure = nextPressure
    gradient = nextGradient
    display = nextDisplay
    buf = nextBuf
    velocity = nextVelocity
    dye = nextDye
    pressureFbo = nextPressureFbo
    divergenceFbo = nextDivergenceFbo
    atlas = nextAtlas
    currentCharset = options.charset

    clearFbo(velocity.read, 0.5, 0.5, 0.5)
    clearFbo(velocity.write, 0.5, 0.5, 0.5)
    clearFbo(dye.read)
    clearFbo(dye.write)
    return true
  }

  if (!initGl()) return null

  let raf = 0
  let destroyed = false
  // Set while the WebGL context is lost; nothing may touch gl until restored.
  let lost = false
  let onScreen = true
  let last = performance.now()
  const start = last

  function wake() {
    if (raf || destroyed || lost) return
    last = performance.now()
    raf = requestAnimationFrame(tick)
  }

  /**
   * Draws now: resizing cleared the canvas, and waiting for the next frame
   * would show it blank for one. Zero dt, so time doesn't jump; the tick
   * reschedules itself if it should keep running.
   */
  const redraw = () => {
    if (destroyed || lost) return
    cancelAnimationFrame(raf)
    raf = 0
    last = performance.now()
    tick(last)
  }

  /** Returns whether the backing size changed (which clears the canvas). */
  const resize = () => {
    const parent = canvas.parentElement
    if (!parent) return false
    const dpr = Math.min(window.devicePixelRatio || 1, 2)
    const w = parent.clientWidth
    const h = parent.clientHeight
    if (w <= 0 || h <= 0) return false
    const width = Math.max(1, Math.floor(w * dpr))
    const height = Math.max(1, Math.floor(h * dpr))
    canvas.style.width = `${w}px`
    canvas.style.height = `${h}px`
    if (width === canvas.width && height === canvas.height) return false
    canvas.width = width
    canvas.height = height
    return true
  }

  resize()
  // Resizing clears the canvas, so redraw even when the loop is idle.
  const ro = new ResizeObserver(() => {
    if (resize()) redraw()
    else wake()
  })
  if (canvas.parentElement) ro.observe(canvas.parentElement)

  const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)")
  const onReduce = () => wake()
  mqReduce.addEventListener("change", onReduce)

  const onPointer = (e: PointerEvent) => {
    if (!options.interactive) return
    const parent = canvas.parentElement
    if (!parent) return
    const rect = parent.getBoundingClientRect()
    if (rect.width <= 0 || rect.height <= 0) return
    const x = (e.clientX - rect.left) / rect.width
    const y = 1 - (e.clientY - rect.top) / rect.height
    mouse.inside = x >= 0 && x <= 1 && y >= 0 && y <= 1
    mouse.dx = x - mouse.x
    mouse.dy = y - mouse.y
    mouse.x = x
    mouse.y = y
    mouse.moved = true
  }
  const onLeave = () => {
    mouse.inside = false
  }

  window.addEventListener("pointermove", onPointer, { passive: true })
  const parentEl = canvas.parentElement
  parentEl?.addEventListener("pointerleave", onLeave, { passive: true })

  const tick = (now: number) => {
    raf = 0
    if (destroyed || lost) return
    // Off screen or in a hidden tab: stop until an observer wakes the loop.
    if (!onScreen || document.visibilityState === "hidden") return
    reduce = mqReduce.matches
    const dt = Math.min(Math.max(0, (now - last) / 1000), 0.033)
    last = now
    const time = (now - start) / 1000
    const p = options

    if (p.charset !== currentCharset) {
      const next = buildAtlas(gl, p.charset)
      if (next) {
        gl.deleteTexture(atlas.tex)
        atlas = next
        currentCharset = p.charset
      }
    }

    const dark = resolveDark(p.theme)
    const ink = hexToRgb(p.color ?? (dark ? DARK.ink : LIGHT.ink))
    const paper = hexToRgb(
      p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)
    )
    const texel = [1 / SIM, 1 / SIM] as const
    const aspect = canvas.width / Math.max(canvas.height, 1)
    const brushR = 0.00012 + Math.max(0.05, Math.min(1, p.brush)) * 0.0011

    if (p.interactive && mouse.moved && mouse.inside && !reduce) {
      const speed = Math.hypot(mouse.dx, mouse.dy)
      const strength = p.force * (18 + speed * 120)

      // Velocity trail
      bindQuad(splat.program)
      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
      gl.uniform1i(gl.getUniformLocation(splat.program, "u_target"), 0)
      gl.uniform2f(
        gl.getUniformLocation(splat.program, "u_point"),
        mouse.x,
        mouse.y
      )
      gl.uniform3f(
        gl.getUniformLocation(splat.program, "u_color"),
        mouse.dx * strength,
        mouse.dy * strength,
        0
      )
      gl.uniform1f(gl.getUniformLocation(splat.program, "u_radius"), brushR)
      gl.uniform1f(gl.getUniformLocation(splat.program, "u_aspect"), aspect)
      gl.uniform1f(gl.getUniformLocation(splat.program, "u_velocityField"), 1)
      blit(velocity.write)
      velocity.swap()

      // Dye trail, denser with speed so fast moves write brighter glyphs
      const dyeAmt = Math.min(1.4, 0.45 + speed * 8) * p.force
      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)
      gl.uniform1i(gl.getUniformLocation(splat.program, "u_target"), 0)
      gl.uniform2f(
        gl.getUniformLocation(splat.program, "u_point"),
        mouse.x,
        mouse.y
      )
      gl.uniform3f(
        gl.getUniformLocation(splat.program, "u_color"),
        dyeAmt,
        0,
        0
      )
      gl.uniform1f(
        gl.getUniformLocation(splat.program, "u_radius"),
        brushR * 1.15
      )
      gl.uniform1f(gl.getUniformLocation(splat.program, "u_aspect"), aspect)
      gl.uniform1f(gl.getUniformLocation(splat.program, "u_velocityField"), 0)
      blit(dye.write)
      dye.swap()

      mouse.moved = false
      mouse.dx = 0
      mouse.dy = 0
    }

    // Soft ambient swirl so the field never fully dies when idle
    if (p.animate && !reduce && !mouse.inside) {
      const ax = Math.sin(time * 0.55) * 0.22
      const ay = Math.cos(time * 0.42) * 0.22
      bindQuad(splat.program)
      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
      gl.uniform1i(gl.getUniformLocation(splat.program, "u_target"), 0)
      gl.uniform2f(
        gl.getUniformLocation(splat.program, "u_point"),
        0.5 + Math.sin(time * 0.23) * 0.22,
        0.5 + Math.cos(time * 0.19) * 0.18
      )
      gl.uniform3f(gl.getUniformLocation(splat.program, "u_color"), ax, ay, 0)
      gl.uniform1f(gl.getUniformLocation(splat.program, "u_radius"), 0.0018)
      gl.uniform1f(gl.getUniformLocation(splat.program, "u_aspect"), aspect)
      gl.uniform1f(gl.getUniformLocation(splat.program, "u_velocityField"), 1)
      blit(velocity.write)
      velocity.swap()
    }

    if (!reduce) {
      bindQuad(advect.program)
      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
      gl.uniform1i(gl.getUniformLocation(advect.program, "u_velocity"), 0)
      gl.activeTexture(gl.TEXTURE1)
      gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
      gl.uniform1i(gl.getUniformLocation(advect.program, "u_source"), 1)
      gl.uniform2f(
        gl.getUniformLocation(advect.program, "u_texel"),
        texel[0],
        texel[1]
      )
      gl.uniform1f(gl.getUniformLocation(advect.program, "u_dt"), dt)
      gl.uniform1f(
        gl.getUniformLocation(advect.program, "u_dissipation"),
        1 - Math.min(0.18, p.dissipation * 2.5)
      )
      gl.uniform1f(gl.getUniformLocation(advect.program, "u_velocityField"), 1)
      blit(velocity.write)
      velocity.swap()

      bindQuad(divergence.program)
      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
      gl.uniform1i(gl.getUniformLocation(divergence.program, "u_velocity"), 0)
      gl.uniform2f(
        gl.getUniformLocation(divergence.program, "u_texel"),
        texel[0],
        texel[1]
      )
      blit(divergenceFbo)

      clearFbo(pressureFbo.read, 0.5, 0.5, 0.5)
      clearFbo(pressureFbo.write, 0.5, 0.5, 0.5)
      bindQuad(pressure.program)
      for (let i = 0; i < 14; i++) {
        gl.activeTexture(gl.TEXTURE0)
        gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)
        gl.uniform1i(gl.getUniformLocation(pressure.program, "u_pressure"), 0)
        gl.activeTexture(gl.TEXTURE1)
        gl.bindTexture(gl.TEXTURE_2D, divergenceFbo.tex)
        gl.uniform1i(gl.getUniformLocation(pressure.program, "u_divergence"), 1)
        gl.uniform2f(
          gl.getUniformLocation(pressure.program, "u_texel"),
          texel[0],
          texel[1]
        )
        blit(pressureFbo.write)
        pressureFbo.swap()
      }

      bindQuad(gradient.program)
      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, pressureFbo.read.tex)
      gl.uniform1i(gl.getUniformLocation(gradient.program, "u_pressure"), 0)
      gl.activeTexture(gl.TEXTURE1)
      gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
      gl.uniform1i(gl.getUniformLocation(gradient.program, "u_velocity"), 1)
      gl.uniform2f(
        gl.getUniformLocation(gradient.program, "u_texel"),
        texel[0],
        texel[1]
      )
      blit(velocity.write)
      velocity.swap()

      bindQuad(advect.program)
      gl.activeTexture(gl.TEXTURE0)
      gl.bindTexture(gl.TEXTURE_2D, velocity.read.tex)
      gl.uniform1i(gl.getUniformLocation(advect.program, "u_velocity"), 0)
      gl.activeTexture(gl.TEXTURE1)
      gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)
      gl.uniform1i(gl.getUniformLocation(advect.program, "u_source"), 1)
      gl.uniform2f(
        gl.getUniformLocation(advect.program, "u_texel"),
        texel[0],
        texel[1]
      )
      gl.uniform1f(gl.getUniformLocation(advect.program, "u_dt"), dt)
      gl.uniform1f(
        gl.getUniformLocation(advect.program, "u_dissipation"),
        1 - Math.min(0.22, Math.max(0.02, p.dissipation))
      )
      gl.uniform1f(gl.getUniformLocation(advect.program, "u_velocityField"), 0)
      blit(dye.write)
      dye.swap()
    }

    bindQuad(display.program)
    gl.activeTexture(gl.TEXTURE0)
    gl.bindTexture(gl.TEXTURE_2D, dye.read.tex)
    gl.uniform1i(gl.getUniformLocation(display.program, "u_dye"), 0)
    gl.activeTexture(gl.TEXTURE1)
    gl.bindTexture(gl.TEXTURE_2D, atlas.tex)
    gl.uniform1i(gl.getUniformLocation(display.program, "u_atlas"), 1)
    gl.uniform2f(
      gl.getUniformLocation(display.program, "u_resolution"),
      canvas.width,
      canvas.height
    )
    const cell =
      Math.max(7, p.cellSize) * Math.min(window.devicePixelRatio || 1, 2)
    gl.uniform2f(gl.getUniformLocation(display.program, "u_cell"), cell, cell)
    gl.uniform1f(
      gl.getUniformLocation(display.program, "u_charCount"),
      atlas.count
    )
    gl.uniform3f(
      gl.getUniformLocation(display.program, "u_ink"),
      ink[0],
      ink[1],
      ink[2]
    )
    gl.uniform3f(
      gl.getUniformLocation(display.program, "u_paper"),
      paper[0],
      paper[1],
      paper[2]
    )
    gl.uniform1f(gl.getUniformLocation(display.program, "u_time"), time)
    gl.uniform1f(
      gl.getUniformLocation(display.program, "u_animate"),
      p.animate && !reduce ? 1 : 0
    )
    blit(null)

    // Reduced motion skips the simulation, so every frame would be the same;
    // hold this one until a resize, theme or option change wakes the loop.
    if (!reduce) raf = requestAnimationFrame(tick)
  }

  const io = new IntersectionObserver(([entry]) => {
    onScreen = entry?.isIntersecting ?? true
    wake()
  })
  io.observe(canvas)
  const onVisibility = () => wake()
  document.addEventListener("visibilitychange", onVisibility)
  const onTheme = () => wake()
  const mo = new MutationObserver(onTheme)
  mo.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ["class", "data-theme"],
  })
  const mqDark = window.matchMedia("(prefers-color-scheme: dark)")
  mqDark.addEventListener("change", onTheme)

  // Without preventDefault() the browser never restores a lost context.
  const onContextLost = (e: Event) => {
    e.preventDefault()
    lost = true
    cancelAnimationFrame(raf)
    raf = 0
  }
  // Every GL object died with the old context, so rebuild them all and
  // restart the fluid empty. If that fails, stay quietly blank.
  const onContextRestored = () => {
    if (destroyed || !initGl()) return
    lost = false
    mouse.moved = false
    mouse.dx = 0
    mouse.dy = 0
    wake()
  }
  canvas.addEventListener("webglcontextlost", onContextLost)
  canvas.addEventListener("webglcontextrestored", onContextRestored)

  wake()

  return {
    setOptions(next) {
      options = { ...options, ...next }
      wake()
    },
    destroy() {
      destroyed = true
      cancelAnimationFrame(raf)
      raf = 0
      ro.disconnect()
      io.disconnect()
      mo.disconnect()
      document.removeEventListener("visibilitychange", onVisibility)
      mqReduce.removeEventListener("change", onReduce)
      mqDark.removeEventListener("change", onTheme)
      window.removeEventListener("pointermove", onPointer)
      parentEl?.removeEventListener("pointerleave", onLeave)
      canvas.removeEventListener("webglcontextlost", onContextLost)
      canvas.removeEventListener("webglcontextrestored", onContextRestored)
      gl.deleteProgram(splat.program)
      gl.deleteProgram(advect.program)
      gl.deleteProgram(divergence.program)
      gl.deleteProgram(pressure.program)
      gl.deleteProgram(gradient.program)
      gl.deleteProgram(display.program)
      gl.deleteShader(vs)
      gl.deleteShader(splat.fs)
      gl.deleteShader(advect.fs)
      gl.deleteShader(divergence.fs)
      gl.deleteShader(pressure.fs)
      gl.deleteShader(gradient.fs)
      gl.deleteShader(display.fs)
      gl.deleteBuffer(buf)
      gl.deleteTexture(atlas.tex)
      for (const f of [
        velocity.read,
        velocity.write,
        dye.read,
        dye.write,
        pressureFbo.read,
        pressureFbo.write,
        divergenceFbo,
      ]) {
        gl.deleteTexture(f.tex)
        gl.deleteFramebuffer(f.fbo)
      }
      // Free the GPU context once the canvas has left the page; browsers cap
      // live contexts at about 16. A canvas still in the page (a React
      // Strict Mode replay) keeps it, so a new engine can reuse it.
      const lose = gl.getExtension("WEBGL_lose_context")
      queueMicrotask(() => {
        if (!canvas.isConnected && !gl.isContextLost()) lose?.loseContext()
      })
    },
  }
}

export type AsciiFluidProps = AsciiFluidOptions & {
  className?: string
}

/**
 * ASCII fluid background: pointer trails leave ink that swirls and
 * quantizes to a clean brightness-mapped glyph field. Zero deps.
 */
export function AsciiFluid({
  className,
  charset = DEFAULT_CHARSET,
  cellSize = 12,
  color,
  backgroundColor,
  force = 1,
  dissipation = 0.05,
  brush = 0.55,
  animate = true,
  interactive = true,
  theme = "auto",
}: AsciiFluidProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const instanceRef = useRef<AsciiFluidInstance | null>(null)

  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    instanceRef.current = createAsciiFluid(canvas, {
      charset,
      cellSize,
      color,
      backgroundColor,
      force,
      dissipation,
      brush,
      animate,
      interactive,
      theme,
    })
    return () => {
      instanceRef.current?.destroy()
      instanceRef.current = null
    }
    // Engine reads live options via setOptions; mount once.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useEffect(() => {
    instanceRef.current?.setOptions({
      charset,
      cellSize,
      color,
      backgroundColor,
      force,
      dissipation,
      brush,
      animate,
      interactive,
      theme,
    })
  }, [
    charset,
    cellSize,
    color,
    backgroundColor,
    force,
    dissipation,
    brush,
    animate,
    interactive,
    theme,
  ])

  return (
    <div
      data-slot="ascii-fluid"
      aria-hidden
      className={cn(
        "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [mask-image:linear-gradient(#000,#000)]",
        className
      )}
    >
      {/* Firefox can hand an opaque WebGL canvas straight to the system
          compositor, which ignores rounded clips. The mask on the root keeps
          it in the page's own layer, so the parent's radius holds. */}
      <canvas
        ref={canvasRef}
        className="absolute inset-0 size-full rounded-[inherit]"
      />
    </div>
  )
}

More from 23rd

Browse all components in this library →