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>
)
}