← All moods

Acid Blur

Normal text (in the CSS color) gradually turns into a thermal-camera image of itself. The heat spreads out of the letters like diffusion: the letters go soft and out of focus, cores glow mint, the edges burn orange and pink, a magenta glow wraps every side, and teal smoke trails off in streaks. Film grain runs through all of it. After the transition, the grain and smoke keep drifting (live).

React · Motion

Color
Background

AcidBlur.tsx

"use client";

import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { animate, useInView, useMotionValue, useReducedMotion } from "motion/react";

export interface AcidBlurProps {
  text: string;
  /** Heat-map ramp, coolest (faint haze) → hottest (letter cores). */
  colors?: string[];
  /** Text color before the effect kicks in. Defaults to the CSS color. */
  color?: string;
  /** Seconds from plain text to the full effect. */
  duration?: number;
  delay?: number;
  /** Length of the smoky trail, em. */
  smear?: number;
  /** Trail direction, degrees (0 = right, 90 = down, 225 = up-left). */
  angle?: number;
  /** Glow size, em. */
  glow?: number;
  /** Grain amount, 0–1. */
  grain?: number;
  /** Keep the grain and smoke drifting after the effect has arrived. */
  live?: boolean;
  trigger?: "load" | "inView";
  className?: string;
  style?: CSSProperties;
}

const VERT = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
  vUv = aPos * 0.5 + 0.5;
  vUv.y = 1.0 - vUv.y;
  gl_Position = vec4(aPos, 0.0, 1.0);
}`;

const FRAG = `
precision highp float;
varying vec2 vUv;
uniform sampler2D uSharp;
// The text blurred at four radii (glow × 0.35, 0.8, 1.8, 3.6). The shader
// blends between them to get any blur radius in that range.
uniform sampler2D uB1;
uniform sampler2D uB2;
uniform sampler2D uB3;
uniform sampler2D uB4;
uniform sampler2D uRamp;
uniform vec2 uSmear;      // full trail offset, uv
uniform vec3 uColor;      // plain text color
uniform float uP;         // progress 0..1
uniform float uTime;
uniform float uGrain;
uniform vec2 uRes;

float hash(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}
float noise(vec2 p) {
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
             mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
}
float fbm(vec2 p) {
  float v = 0.0, a = 0.5;
  for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
  return v;
}

// Text blurred by a continuous "level": 0 = sharp, 1..4 = the blur textures.
float blurAt(vec2 uv, float level) {
  float s0 = texture2D(uSharp, uv).r;
  float s1 = texture2D(uB1, uv).r;
  if (level <= 1.0) return mix(s0, s1, level);
  float s2 = texture2D(uB2, uv).r;
  if (level <= 2.0) return mix(s1, s2, level - 1.0);
  float s3 = texture2D(uB3, uv).r;
  if (level <= 3.0) return mix(s2, s3, level - 2.0);
  return mix(s3, texture2D(uB4, uv).r, clamp(level - 3.0, 0.0, 1.0));
}

void main() {
  float p = uP;
  float sharp = texture2D(uSharp, vUv).r;
  // Heat spreads like diffusion: the spread radius grows with sqrt(time).
  float spread = sqrt(p);

  // 1. The letters themselves defocus: their edges soften to ~glow×0.3, enough
  //    to read as out of focus while staying legible.
  float core = blurAt(vUv, 0.9 * spread);
  // 2. Halo all around each letter (top, sides and bottom alike).
  float halo = texture2D(uB3, vUv).r;
  // 3. Wide, faint haze.
  float haze = texture2D(uB4, vUv).r;

  // 4. Smoke: heat carried off along the trail direction, sampled from ever
  //    wider blurs the farther it travels, bent into wisps by noise stretched
  //    along the direction, drifting over time.
  vec2 dir = normalize(uSmear + 1e-6);
  vec2 perp = vec2(-dir.y, dir.x);
  float along = dot(vUv * uRes, dir) / uRes.y;
  float across = dot(vUv * uRes, perp) / uRes.y;
  float streak = fbm(vec2(across * 16.0, along * 1.5 - uTime * 0.12));
  float smear = 0.0;
  float total = 0.0;
  for (int i = 1; i <= 20; i++) {
    float t = float(i) / 20.0;
    vec2 warp = perp * (streak - 0.5) * 0.09 * t
              + (vec2(fbm(vUv * 2.5 + uTime * 0.04), fbm(vUv * 2.5 - uTime * 0.03 + 5.1)) - 0.5) * 0.05 * t;
    float w = pow(1.0 - t, 1.1);
    smear += blurAt(vUv - uSmear * t * p + warp * p, 2.0 + 2.0 * t) * w;
    total += w;
  }
  smear = smear / total * (0.25 + 1.5 * streak);

  // Heat: letter cores hottest (mint), their soft edges fall through
  // peach/orange/pink, the halo is magenta/purple, haze and smoke are teal.
  float heat = core * mix(1.0, 0.9, p) + p * (halo * 0.32 + haze * 0.16 + smear * 0.5);

  // Film grain. It scales with the heat, so empty background stays clean
  // while the glow and the letters get gritty.
  float g = hash(gl_FragCoord.xy + fract(uTime * 0.37) * 517.0) - 0.5;
  // Gentler in the hottest cores so they read as a solid glow, like a real
  // thermal image; grittiest in the falloff.
  heat *= 1.0 + g * uGrain * p * 1.4 * (1.0 - 0.75 * smoothstep(0.75, 1.05, heat));
  heat = clamp(heat, 0.0, 1.0);

  vec3 ramp = texture2D(uRamp, vec2(heat, 0.5)).rgb;
  float alpha = smoothstep(0.03, 0.25, heat);
  vec4 acid = vec4(ramp * alpha, alpha);
  vec4 plain = vec4(uColor * sharp, sharp);
  gl_FragColor = mix(plain, acid, smoothstep(0.0, 0.55, p));
}`;

function parseColor(css: string): [number, number, number] {
  const ctx = document.createElement("canvas").getContext("2d")!;
  ctx.fillStyle = css;
  ctx.fillRect(0, 0, 1, 1);
  const d = ctx.getImageData(0, 0, 1, 1).data;
  return [d[0] / 255, d[1] / 255, d[2] / 255];
}

export function AcidBlur({
  text,
  colors = ["#0b2a33", "#1d6b74", "#5b2aa0", "#c42aa8", "#ff4f9a", "#ff8a3d", "#f7c27a", "#a3f0b2"],
  color,
  duration = 3,
  delay = 0.3,
  smear = 5,
  angle = 250,
  glow = 0.14,
  grain = 0.45,
  live = true,
  trigger = "load",
  className,
  style,
}: AcidBlurProps) {
  const reduceMotion = useReducedMotion();
  const rootRef = useRef<HTMLSpanElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const progress = useMotionValue(0);
  const inView = useInView(rootRef, { once: true, amount: 0.3 });
  const started = trigger === "load" || inView;
  const [failed, setFailed] = useState(false);

  // Room around the text for the glow and trail, em.
  const pad = smear + glow * 3 + 0.2;

  useEffect(() => {
    if (!started) return;
    if (reduceMotion) {
      progress.set(1);
      return;
    }
    const controls = animate(progress, 1, { duration, delay, ease: [0.4, 0, 0.2, 1] });
    return () => controls.stop();
  }, [started, reduceMotion, duration, delay, progress]);

  useLayoutEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const gl = canvas.getContext("webgl", { premultipliedAlpha: true, alpha: true });
    if (!gl) {
      setFailed(true);
      return;
    }

    const compile = (type: number, src: string) => {
      const s = gl.createShader(type)!;
      gl.shaderSource(s, src);
      gl.compileShader(s);
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(s));
      return s;
    };
    const prog = gl.createProgram()!;
    gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
    gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
    gl.linkProgram(prog);
    gl.useProgram(prog);
    const buf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
    const aPos = gl.getAttribLocation(prog, "aPos");
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
    const u = (name: string) => gl.getUniformLocation(prog, name);

    const textures = [0, 1, 2, 3, 4, 5].map((unit) => {
      const tex = gl.createTexture()!;
      gl.activeTexture(gl.TEXTURE0 + unit);
      gl.bindTexture(gl.TEXTURE_2D, tex);
      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);
      return tex;
    });
    ["uSharp", "uB1", "uB2", "uB3", "uB4", "uRamp"].forEach((name, i) => gl.uniform1i(u(name), i));
    const upload = (unit: number, source: HTMLCanvasElement) => {
      gl.activeTexture(gl.TEXTURE0 + unit);
      gl.bindTexture(gl.TEXTURE_2D, textures[unit]);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
    };

    // Heat ramp as a 256×1 gradient texture.
    const ramp = document.createElement("canvas");
    ramp.width = 256;
    ramp.height = 1;
    const rctx = ramp.getContext("2d")!;
    const grad = rctx.createLinearGradient(0, 0, 256, 0);
    colors.forEach((c, i) => grad.addColorStop(i / Math.max(colors.length - 1, 1), c));
    rctx.fillStyle = grad;
    rctx.fillRect(0, 0, 256, 1);
    upload(5, ramp);

    // Draw the text mask (white on black) in the element's font, plus two
    // blurred copies. Redone whenever the size or font changes.
    let dirty = true;
    const build = () => {
      const cs = getComputedStyle(root);
      const fs = parseFloat(cs.fontSize) || 16;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const rect = root.getBoundingClientRect();
      const w = Math.max(1, Math.round((rect.width + pad * 2 * fs) * dpr));
      const h = Math.max(1, Math.round((rect.height + pad * 2 * fs) * dpr));
      canvas.width = w;
      canvas.height = h;
      gl.viewport(0, 0, w, h);

      const mask = document.createElement("canvas");
      mask.width = w;
      mask.height = h;
      const m = mask.getContext("2d")!;
      m.fillStyle = "#000";
      m.fillRect(0, 0, w, h);
      m.fillStyle = "#fff";
      m.font = `${cs.fontStyle} ${cs.fontWeight} ${fs * dpr}px ${cs.fontFamily}`;
      if ("letterSpacing" in m && cs.letterSpacing !== "normal") {
        (m as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
          `${parseFloat(cs.letterSpacing) * dpr}px`;
      }
      m.textAlign = "center";
      m.textBaseline = "middle";
      m.fillText(text, w / 2, h / 2);
      upload(0, mask);

      const blurred = (radiusPx: number) => {
        const c = document.createElement("canvas");
        c.width = w;
        c.height = h;
        const b = c.getContext("2d")!;
        b.fillStyle = "#000";
        b.fillRect(0, 0, w, h);
        if (typeof (b as { filter?: string }).filter === "string") {
          b.filter = `blur(${radiusPx}px)`;
          b.drawImage(mask, 0, 0);
        } else {
          // No canvas filter: downscale then upscale with smoothing.
          const k = Math.max(1, Math.round(radiusPx / 2));
          const small = document.createElement("canvas");
          small.width = Math.max(1, Math.round(w / k));
          small.height = Math.max(1, Math.round(h / k));
          small.getContext("2d")!.drawImage(mask, 0, 0, small.width, small.height);
          b.imageSmoothingQuality = "high";
          b.drawImage(small, 0, 0, w, h);
        }
        return c;
      };
      [0.35, 0.8, 1.8, 3.6].forEach((k, i) => upload(i + 1, blurred(glow * k * fs * dpr)));

      const rad = (angle * Math.PI) / 180;
      gl.uniform2f(u("uSmear"), (Math.cos(rad) * smear * fs * dpr) / w, (Math.sin(rad) * smear * fs * dpr) / h);
      gl.uniform2f(u("uRes"), w, h);
      gl.uniform3fv(u("uColor"), parseColor(color ?? cs.color));
      gl.uniform1f(u("uGrain"), grain);
      dirty = true;
    };
    build();
    const observer = new ResizeObserver(build);
    observer.observe(root);
    document.fonts?.addEventListener("loadingdone", build);

    let frame = 0;
    let last = -1;
    const start = performance.now();
    const draw = (now: number) => {
      frame = requestAnimationFrame(draw);
      const p = progress.get();
      // Once settled, only keep drawing if the grain/smoke should stay alive.
      if (!dirty && p === last && (!live || reduceMotion || p === 0)) return;
      last = p;
      dirty = false;
      gl.uniform1f(u("uP"), p);
      gl.uniform1f(u("uTime"), reduceMotion ? 0 : (now - start) / 1000);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    };
    frame = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(frame);
      observer.disconnect();
      document.fonts?.removeEventListener("loadingdone", build);
      // Free GPU resources but keep the context: React StrictMode (and fast
      // remounts) reuse the same canvas, and a lost context can't come back.
      textures.forEach((t) => gl.deleteTexture(t));
      gl.deleteBuffer(buf);
      gl.deleteProgram(prog);
    };
  }, [text, colors.join(), color, smear, angle, glow, grain, live, pad, reduceMotion, progress]);

  return (
    <span
      ref={rootRef}
      role="img"
      aria-label={text}
      className={className}
      style={{ position: "relative", display: "inline-block", lineHeight: 1, whiteSpace: "pre", ...style }}
    >
      {/* Invisible copy of the text sizes the box; the canvas draws it. */}
      <span aria-hidden style={{ color: failed ? undefined : "transparent" }}>
        {text}
      </span>
      {!failed && (
        <canvas
          ref={canvasRef}
          aria-hidden
          style={{
            position: "absolute",
            left: `${-pad}em`,
            top: `${-pad}em`,
            width: `calc(100% + ${pad * 2}em)`,
            height: `calc(100% + ${pad * 2}em)`,
            pointerEvents: "none",
          }}
        />
      )}
    </span>
  );
}