AI CSS Animations

Morphing SDF Glyph

One glowing shape morphs from circle to rounded square to star, trailed by fading echoes and filled with inward contour lines. Token speed quickens the morph and the breath.

Also known as: shape morph loader, sdf morph, breathing shape.

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-morphing-sdf-glyph" data-tps="150" data-color="#ff8a4c" 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-morphing-sdf-glyph");
  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;
#define TAU 6.28318530718
float hash11(float p) { p = fract(p * 0.1031); p *= p + 33.33; p *= p + p; return fract(p); }
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); }
// the accent, lifted so a dark pick still glows
vec3 accent() { return uColor / max(0.25, max(uColor.r, max(uColor.g, uColor.b))); }
// rotate a colour's hue around the grey axis, for a companion tone
vec3 hueRot(vec3 c, float a) {
  const vec3 k = vec3(0.57735027);
  float ca = cos(a);
  return max(c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca), 0.0);
}
// 0 at rest, toward 1 as the stream speeds up
float energy() { return 1.0 - exp(-max(uTps, 0.0) / 90.0); }
vec3 stage(vec2 uv) { return vec3(0.008, 0.009, 0.013) + accent() * 0.016 * exp(-dot(uv, uv) * 2.2); }
// soft shoulder so hot cores bloom toward white, plus a dither against banding
vec4 finish(vec3 c, vec2 fc) {
  c = 1.0 - exp(-max(c, 0.0) * 1.35);
  c += (hash12(fc) + hash12(fc + 17.13) - 1.0) / 255.0;
  return vec4(c, 1.0);
}

float sdRBox(vec2 p, vec2 b, float r) {
  vec2 q = abs(p) - b + r;
  return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
float sdStar5(vec2 p, float r, float rf) {
  const vec2 k1 = vec2(0.809016994375, -0.587785252292);
  const vec2 k2 = vec2(-k1.x, k1.y);
  p.x = abs(p.x);
  p -= 2.0 * max(dot(k1, p), 0.0) * k1;
  p -= 2.0 * max(dot(k2, p), 0.0) * k2;
  p.x = abs(p.x);
  p.y -= r;
  vec2 ba = rf * vec2(-k1.y, k1.x) - vec2(0.0, 1.0);
  float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, r);
  return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y);
}
// circle, rounded square, star, and back, holding a beat on each
float shape(vec2 p, float m) {
  float seg = floor(m);
  float t = smoothstep(0.2, 0.8, fract(m));
  float c = length(p) - 0.25;
  float b = sdRBox(p, vec2(0.205), 0.07);
  float s = sdStar5(p, 0.3, 0.5) - 0.025;
  float d0 = seg < 0.5 ? c : (seg < 1.5 ? b : s);
  float d1 = seg < 0.5 ? b : (seg < 1.5 ? s : c);
  return mix(d0, d1, t);
}
mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
  vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
  float px = 1.0 / iResolution.y;
  float e = energy();
  vec3 A = accent();
  vec3 B = hueRot(A, -1.0);
  vec3 col = stage(uv);

  float m = mod(uTokens * 0.002 + iTime * 0.06, 3.0);
  float spin = mod(uTokens * 0.0008 + iTime * 0.04, TAU);
  float breath = 1.0 + (0.015 + 0.05 * e) * sin(mod(iTime * 2.4, TAU));
  const float Z = 1.18;
  vec2 p = rot(spin) * uv / (breath * Z);
  float d = shape(p, m) * breath * Z;

  // echoes: earlier forms of the shape, larger and fainter
  for (int j = 1; j <= 4; j++) {
    float fj = float(j);
    float sc = 1.0 + fj * (0.12 + 0.08 * e);
    float mj = mod(m - fj * 0.07, 3.0);
    vec2 q = rot(spin - fj * 0.05) * uv / (sc * breath * Z);
    float dj = shape(q, mj) * sc * breath * Z;
    float fade = (1.0 - fj / 5.0);
    col += mix(B, A, fj / 4.0) * (smoothstep(px * 1.2, 0.0, abs(dj)) * 0.5 + exp(-abs(dj) * 60.0) * 0.12) * fade * fade * (0.4 + 0.6 * e);
  }

  float inside = smoothstep(px, -px, d);
  // contour bands that flow inward with the tokens
  float bp = fract(uTokens * 0.016 + iTime * 0.12);
  float bc = -d / 0.028 + bp;
  float band = abs(fract(bc + 0.5) - 0.5);
  float bw = fwidth(bc);
  float lines = smoothstep(0.06 + bw, 0.06, band);
  float depth = clamp(-d / 0.25, 0.0, 1.0);
  vec3 fill = A * (0.08 + 0.1 * (1.0 - depth)) + mix(A, B, depth) * lines * (1.0 - depth) * (0.25 + 0.35 * e);
  col = mix(col, fill, inside);
  // the outline, with bloom
  col += mix(A, vec3(1.0), 0.55) * smoothstep(0.003 + px, 0.003 - px * 0.5, abs(d));
  col += A * (exp(-abs(d) * 50.0) * 0.35 + exp(-max(d, 0.0) * 9.0) * 0.12) * (0.6 + 0.6 * e);
  fragColor = finish(col, fragCoord);
}

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 150.0
#define uTokens (iTime * uTps)
#define uColor vec3(1.000, 0.541, 0.298)

#define TAU 6.28318530718
float hash11(float p) { p = fract(p * 0.1031); p *= p + 33.33; p *= p + p; return fract(p); }
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); }
// the accent, lifted so a dark pick still glows
vec3 accent() { return uColor / max(0.25, max(uColor.r, max(uColor.g, uColor.b))); }
// rotate a colour's hue around the grey axis, for a companion tone
vec3 hueRot(vec3 c, float a) {
  const vec3 k = vec3(0.57735027);
  float ca = cos(a);
  return max(c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca), 0.0);
}
// 0 at rest, toward 1 as the stream speeds up
float energy() { return 1.0 - exp(-max(uTps, 0.0) / 90.0); }
vec3 stage(vec2 uv) { return vec3(0.008, 0.009, 0.013) + accent() * 0.016 * exp(-dot(uv, uv) * 2.2); }
// soft shoulder so hot cores bloom toward white, plus a dither against banding
vec4 finish(vec3 c, vec2 fc) {
  c = 1.0 - exp(-max(c, 0.0) * 1.35);
  c += (hash12(fc) + hash12(fc + 17.13) - 1.0) / 255.0;
  return vec4(c, 1.0);
}

float sdRBox(vec2 p, vec2 b, float r) {
  vec2 q = abs(p) - b + r;
  return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
float sdStar5(vec2 p, float r, float rf) {
  const vec2 k1 = vec2(0.809016994375, -0.587785252292);
  const vec2 k2 = vec2(-k1.x, k1.y);
  p.x = abs(p.x);
  p -= 2.0 * max(dot(k1, p), 0.0) * k1;
  p -= 2.0 * max(dot(k2, p), 0.0) * k2;
  p.x = abs(p.x);
  p.y -= r;
  vec2 ba = rf * vec2(-k1.y, k1.x) - vec2(0.0, 1.0);
  float h = clamp(dot(p, ba) / dot(ba, ba), 0.0, r);
  return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y);
}
// circle, rounded square, star, and back, holding a beat on each
float shape(vec2 p, float m) {
  float seg = floor(m);
  float t = smoothstep(0.2, 0.8, fract(m));
  float c = length(p) - 0.25;
  float b = sdRBox(p, vec2(0.205), 0.07);
  float s = sdStar5(p, 0.3, 0.5) - 0.025;
  float d0 = seg < 0.5 ? c : (seg < 1.5 ? b : s);
  float d1 = seg < 0.5 ? b : (seg < 1.5 ? s : c);
  return mix(d0, d1, t);
}
mat2 rot(float a) { float c = cos(a), s = sin(a); return mat2(c, -s, s, c); }

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
  vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
  float px = 1.0 / iResolution.y;
  float e = energy();
  vec3 A = accent();
  vec3 B = hueRot(A, -1.0);
  vec3 col = stage(uv);

  float m = mod(uTokens * 0.002 + iTime * 0.06, 3.0);
  float spin = mod(uTokens * 0.0008 + iTime * 0.04, TAU);
  float breath = 1.0 + (0.015 + 0.05 * e) * sin(mod(iTime * 2.4, TAU));
  const float Z = 1.18;
  vec2 p = rot(spin) * uv / (breath * Z);
  float d = shape(p, m) * breath * Z;

  // echoes: earlier forms of the shape, larger and fainter
  for (int j = 1; j <= 4; j++) {
    float fj = float(j);
    float sc = 1.0 + fj * (0.12 + 0.08 * e);
    float mj = mod(m - fj * 0.07, 3.0);
    vec2 q = rot(spin - fj * 0.05) * uv / (sc * breath * Z);
    float dj = shape(q, mj) * sc * breath * Z;
    float fade = (1.0 - fj / 5.0);
    col += mix(B, A, fj / 4.0) * (smoothstep(px * 1.2, 0.0, abs(dj)) * 0.5 + exp(-abs(dj) * 60.0) * 0.12) * fade * fade * (0.4 + 0.6 * e);
  }

  float inside = smoothstep(px, -px, d);
  // contour bands that flow inward with the tokens
  float bp = fract(uTokens * 0.016 + iTime * 0.12);
  float bc = -d / 0.028 + bp;
  float band = abs(fract(bc + 0.5) - 0.5);
  float bw = fwidth(bc);
  float lines = smoothstep(0.06 + bw, 0.06, band);
  float depth = clamp(-d / 0.25, 0.0, 1.0);
  vec3 fill = A * (0.08 + 0.1 * (1.0 - depth)) + mix(A, B, depth) * lines * (1.0 - depth) * (0.25 + 0.35 * e);
  col = mix(col, fill, inside);
  // the outline, with bloom
  col += mix(A, vec3(1.0), 0.55) * smoothstep(0.003 + px, 0.003 - px * 0.5, abs(d));
  col += A * (exp(-abs(d) * 50.0) * 0.35 + exp(-max(d, 0.0) * 9.0) * 0.12) * (0.6 + 0.6 * e);
  fragColor = finish(col, fragCoord);
}

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 #ff8a4c.
  • 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