AI CSS Animations

Ink Bloom

Drops of ink sink through dim water and unfurl into curling, dark edged tendrils at different depths. More tokens per second drop more ink and make every bloom unfurl faster.

Also known as: ink in water shader, ai thinking ink animation, ink drop bloom glsl.

HTML + JS

A canvas and a module script, nothing to install. Set canvas.dataset.tps from your stream, or call canvas.token() once per token.

<canvas id="shader-ink-bloom" data-tps="10" data-color="#3d7bff" style="display:block;width:100%;height:240px;border-radius:12px"></canvas>
<script type="module">
  // Set canvas.dataset.tps from your stream, or call canvas.token() per token.
  const canvas = document.getElementById("shader-ink-bloom");
  const gl = canvas.getContext("webgl2", { alpha: false, antialias: false });
  const fragment = `#version 300 es
precision highp float;
uniform vec3 iResolution;
uniform float iTime;
uniform float uTokens;
uniform float uTps;
uniform vec3 uColor;
out vec4 outColor;
// Gradient noise on a lattice that repeats every PER cells. Moving offsets are
// wrapped to whole periods, so the motion loops without a seam.
const float PER = 32.0;
// Octave turn and scale: an integer matrix keeps the lattice periodic.
const mat2 OCT = mat2(2.0, -1.0, 1.0, 2.0);

vec2 grad(vec2 i) {
  i = mod(i, PER);
  vec3 q = fract(vec3(i.xyx) * vec3(0.1031, 0.1030, 0.0973));
  q += dot(q, q.yzx + 33.33);
  return fract((q.xx + q.yz) * q.zy) * 2.0 - 1.0;
}

float noise(vec2 p) {
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
  float a = dot(grad(i), f);
  float b = dot(grad(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));
  float c = dot(grad(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));
  float d = dot(grad(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// A phase that climbs with x but wraps to the noise period.
float wrapP(float x) { return mod(x, PER); }

// Rotate a colour around the grey axis: hue shifts that follow uColor.
vec3 hue(vec3 c, float a) {
  const vec3 k = vec3(0.57735);
  float ca = cos(a);
  return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca);
}

float hash12(vec2 p) {
  vec3 q = fract(vec3(p.xyx) * 0.1031);
  q += dot(q, q.yzx + 33.33);
  return fract((q.x + q.y) * q.z);
}

// Soft shoulder tone map plus a one step dither against banding.
vec3 finish(vec3 c, vec2 fc) {
  c = 1.0 - exp(-max(c, 0.0));
  return c + (hash12(fc) - 0.5) / 255.0;
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
  vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
  float e = 1.0 - exp(-uTps / 80.0);

  // six drop slots; each runs its own clock and a fresh drop is born every whole
  // cycle, so a faster stream means more births and quicker unfurling. Clocks
  // wrap by 512 whole cycles and drop seeds use the cycle mod 512: no seam.
  float D = 0.0, F = 0.0;
  for (int i = 0; i < 6; i++) {
    float fi = float(i);
    float ph = mod(uTokens * 0.0021 * (0.82 + 0.08 * fi), 512.0)
             + mod(iTime * (0.016 + 0.003 * fi), 512.0) + fi * 0.37;
    float n = mod(floor(ph), 512.0);
    float age = fract(ph);
    vec2 sd = vec2(n, fi * 7.0);
    float h1 = hash12(sd + 0.5), h2 = hash12(sd + 3.7), h3 = hash12(sd + 9.1);
    // depth: far drops are smaller, paler and lower in contrast
    float z = fract(h3 + fi * 0.618);
    float sz = mix(1.0, 0.5, z);
    float grow = pow(age, 0.55);
    vec2 pos = vec2(-0.85 + 1.7 * (fi + 0.1 + 0.8 * h1) / 6.0,
                    0.3 - 0.22 * h2 - 0.45 * pow(age, 0.8) * sz);
    float R = (0.03 + (0.3 + 0.08 * e) * grow) * sz;
    vec2 lp = (uv - pos) / R;
    vec2 off = vec2(h1, h2) * 13.0 + fi * 3.1;
    // tendrils churn with the drop's age and with every token
    float tw = age * 1.8 + wrapP(uTokens * 0.006 + fi * 5.0);
    // the plume curls: a soft warp that keeps turning as the drop ages
    vec2 w = vec2(noise(lp * 1.1 + off + vec2(0.0, tw)), noise(lp * 1.1 + off.yx + vec2(tw, 5.0)));
    vec2 q = lp + w * (0.7 + 1.1 * grow);
    // a rolling cap below with a wake trailing up behind it
    float r = length((q - vec2(0.0, 0.35 * grow)) * vec2(1.0, 0.75));
    float env = smoothstep(1.3, 0.25, r) * smoothstep(0.0, 0.1, age) * smoothstep(1.0, 0.62, age);
    float n2 = noise(q * 2.4 + off + vec2(0.0, -tw));
    float fil = exp(-abs(n2) / (0.035 + fwidth(n2)));
    float n3 = noise(q * 4.8 + off.yx - vec2(tw, 0.0));
    fil += 0.6 * exp(-abs(n3) / (0.04 + fwidth(n3))) * smoothstep(0.2, 0.9, grow);
    float body = smoothstep(1.05, 0.15, r);
    float amt = env * mix(1.0, 0.45, z) * (1.15 - 0.45 * age);
    D += amt * (body * 0.6 + fil * 0.35);
    F += amt * fil * mix(1.0, 0.3, z);
  }

  // deep water lit from above, falling off to the dark stage at the edges
  float light = exp(-length((uv - vec2(0.0, 0.3)) * vec2(0.42, 0.85)) * 1.3);
  vec3 water = mix(vec3(0.5, 0.58, 0.66), uColor, 0.15);
  vec3 col = water * (0.025 + 0.3 * light);
  // the dye catches the light: thin ink glows in the accent, dense ink deepens
  float cover = 1.0 - exp(-D * 3.0);
  vec3 dye = mix(mix(uColor, vec3(1.0), 0.12) * 1.5, uColor * 0.6, smoothstep(0.45, 1.0, cover));
  dye *= (0.5 + 0.9 * light) * (0.85 + 0.4 * e);
  col = mix(col, dye, cover * 0.92);
  // the tendril edges stay dark: crisp, high contrast filaments
  col = mix(col, uColor * 0.05, clamp(F * 0.85, 0.0, 0.88));
  // a little extra saturation so the dye stays vivid through the tone map
  col = max(mix(vec3(dot(col, vec3(0.3, 0.55, 0.15))), col, 1.35), 0.0);
  col *= 1.0 - 0.35 * dot(uv * vec2(0.55, 1.0), uv * vec2(0.55, 1.0));
  fragColor = vec4(finish(col, fragCoord), 1.0);
}

void main() {
  mainImage(outColor, gl_FragCoord.xy);
  outColor.a = 1.0;
}
`;
  const vertex = `#version 300 es
void main() {
  vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
`;
  const compile = (type, src) => {
    const s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
    return s;
  };
  const program = gl.createProgram();
  gl.attachShader(program, compile(gl.VERTEX_SHADER, vertex));
  gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragment));
  gl.linkProgram(program);
  gl.useProgram(program);
  const u = (n) => gl.getUniformLocation(program, n);
  const [res, time, tokens, rate, color] = ["iResolution", "iTime", "uTokens", "uTps", "uColor"].map(u);
  const hex = parseInt((canvas.dataset.color || "#7c5cff").slice(1), 16);
  gl.uniform3f(color, ((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255);

  // canvas.token(): count real tokens and derive the rate from the last second.
  const stamps = [];
  canvas.token = () => {
    stamps.push(performance.now());
    canvas.dataset.tps = "auto";
  };
  const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
  let count = 0, last = performance.now(), visible = true, frame = 0;
  new IntersectionObserver(([e]) => {
    visible = e.isIntersecting;
    if (visible && !frame) frame = requestAnimationFrame(draw);
  }).observe(canvas);

  function draw(now) {
    frame = 0;
    const dt = Math.min((now - last) / 1000, 0.1);
    last = now;
    while (stamps.length && now - stamps[0] > 1000) stamps.shift();
    const wobble = 1 + 0.25 * Math.sin(now / 588) + 0.15 * Math.sin(now / 233 + 1) + 0.08 * Math.sin(now / 91 + 2);
    const tps = canvas.dataset.tps === "auto" ? stamps.length : Number(canvas.dataset.tps) * wobble;
    count += tps * dt;
    const dpr = Math.min(devicePixelRatio || 1, 2);
    const w = Math.round(canvas.clientWidth * dpr), h = Math.round(canvas.clientHeight * dpr);
    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
    gl.viewport(0, 0, w, h);
    gl.uniform3f(res, w, h, 1);
    gl.uniform1f(time, still ? 2 : now / 1000);
    gl.uniform1f(tokens, still ? Number(canvas.dataset.tps) * 2 : count);
    gl.uniform1f(rate, tps);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    if (visible && !still) frame = requestAnimationFrame(draw);
  }
  frame = requestAnimationFrame(draw);
</script>

GLSL for Shadertoy

// Inputs the site passes in, as constants for Shadertoy.
// uTps: tokens per second. uTokens: tokens so far. uColor: the accent.
#define uTps 10.0
#define uTokens (iTime * uTps)
#define uColor vec3(0.239, 0.482, 1.000)

// Gradient noise on a lattice that repeats every PER cells. Moving offsets are
// wrapped to whole periods, so the motion loops without a seam.
const float PER = 32.0;
// Octave turn and scale: an integer matrix keeps the lattice periodic.
const mat2 OCT = mat2(2.0, -1.0, 1.0, 2.0);

vec2 grad(vec2 i) {
  i = mod(i, PER);
  vec3 q = fract(vec3(i.xyx) * vec3(0.1031, 0.1030, 0.0973));
  q += dot(q, q.yzx + 33.33);
  return fract((q.xx + q.yz) * q.zy) * 2.0 - 1.0;
}

float noise(vec2 p) {
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
  float a = dot(grad(i), f);
  float b = dot(grad(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));
  float c = dot(grad(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));
  float d = dot(grad(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// A phase that climbs with x but wraps to the noise period.
float wrapP(float x) { return mod(x, PER); }

// Rotate a colour around the grey axis: hue shifts that follow uColor.
vec3 hue(vec3 c, float a) {
  const vec3 k = vec3(0.57735);
  float ca = cos(a);
  return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca);
}

float hash12(vec2 p) {
  vec3 q = fract(vec3(p.xyx) * 0.1031);
  q += dot(q, q.yzx + 33.33);
  return fract((q.x + q.y) * q.z);
}

// Soft shoulder tone map plus a one step dither against banding.
vec3 finish(vec3 c, vec2 fc) {
  c = 1.0 - exp(-max(c, 0.0));
  return c + (hash12(fc) - 0.5) / 255.0;
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
  vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
  float e = 1.0 - exp(-uTps / 80.0);

  // six drop slots; each runs its own clock and a fresh drop is born every whole
  // cycle, so a faster stream means more births and quicker unfurling. Clocks
  // wrap by 512 whole cycles and drop seeds use the cycle mod 512: no seam.
  float D = 0.0, F = 0.0;
  for (int i = 0; i < 6; i++) {
    float fi = float(i);
    float ph = mod(uTokens * 0.0021 * (0.82 + 0.08 * fi), 512.0)
             + mod(iTime * (0.016 + 0.003 * fi), 512.0) + fi * 0.37;
    float n = mod(floor(ph), 512.0);
    float age = fract(ph);
    vec2 sd = vec2(n, fi * 7.0);
    float h1 = hash12(sd + 0.5), h2 = hash12(sd + 3.7), h3 = hash12(sd + 9.1);
    // depth: far drops are smaller, paler and lower in contrast
    float z = fract(h3 + fi * 0.618);
    float sz = mix(1.0, 0.5, z);
    float grow = pow(age, 0.55);
    vec2 pos = vec2(-0.85 + 1.7 * (fi + 0.1 + 0.8 * h1) / 6.0,
                    0.3 - 0.22 * h2 - 0.45 * pow(age, 0.8) * sz);
    float R = (0.03 + (0.3 + 0.08 * e) * grow) * sz;
    vec2 lp = (uv - pos) / R;
    vec2 off = vec2(h1, h2) * 13.0 + fi * 3.1;
    // tendrils churn with the drop's age and with every token
    float tw = age * 1.8 + wrapP(uTokens * 0.006 + fi * 5.0);
    // the plume curls: a soft warp that keeps turning as the drop ages
    vec2 w = vec2(noise(lp * 1.1 + off + vec2(0.0, tw)), noise(lp * 1.1 + off.yx + vec2(tw, 5.0)));
    vec2 q = lp + w * (0.7 + 1.1 * grow);
    // a rolling cap below with a wake trailing up behind it
    float r = length((q - vec2(0.0, 0.35 * grow)) * vec2(1.0, 0.75));
    float env = smoothstep(1.3, 0.25, r) * smoothstep(0.0, 0.1, age) * smoothstep(1.0, 0.62, age);
    float n2 = noise(q * 2.4 + off + vec2(0.0, -tw));
    float fil = exp(-abs(n2) / (0.035 + fwidth(n2)));
    float n3 = noise(q * 4.8 + off.yx - vec2(tw, 0.0));
    fil += 0.6 * exp(-abs(n3) / (0.04 + fwidth(n3))) * smoothstep(0.2, 0.9, grow);
    float body = smoothstep(1.05, 0.15, r);
    float amt = env * mix(1.0, 0.45, z) * (1.15 - 0.45 * age);
    D += amt * (body * 0.6 + fil * 0.35);
    F += amt * fil * mix(1.0, 0.3, z);
  }

  // deep water lit from above, falling off to the dark stage at the edges
  float light = exp(-length((uv - vec2(0.0, 0.3)) * vec2(0.42, 0.85)) * 1.3);
  vec3 water = mix(vec3(0.5, 0.58, 0.66), uColor, 0.15);
  vec3 col = water * (0.025 + 0.3 * light);
  // the dye catches the light: thin ink glows in the accent, dense ink deepens
  float cover = 1.0 - exp(-D * 3.0);
  vec3 dye = mix(mix(uColor, vec3(1.0), 0.12) * 1.5, uColor * 0.6, smoothstep(0.45, 1.0, cover));
  dye *= (0.5 + 0.9 * light) * (0.85 + 0.4 * e);
  col = mix(col, dye, cover * 0.92);
  // the tendril edges stay dark: crisp, high contrast filaments
  col = mix(col, uColor * 0.05, clamp(F * 0.85, 0.0, 0.88));
  // a little extra saturation so the dye stays vivid through the tone map
  col = max(mix(vec3(dot(col, vec3(0.3, 0.55, 0.15))), col, 1.35), 0.0);
  col *= 1.0 - 0.35 * dot(uv * vec2(0.55, 1.0), uv * vec2(0.55, 1.0));
  fragColor = vec4(finish(col, fragCoord), 1.0);
}

Customizing this shader

  • Speed: uTps is the token rate and uTokens the running count. The standalone runner adds a natural wobble to a fixed rate, the way real streams speed up and slow down.
  • Color: set data-color on the canvas, or change uColor in the Shadertoy version. Currently previewing #3d7bff.
  • Size: style the canvas with CSS. It renders at up to twice your screen's pixel density and stops drawing while it is off screen.
  • Reduced motion: the runner draws a single still frame for people who asked their OS for less motion.

Similar shaders