Solid text. The letters start to melt: paint in the letters' own color runs off their bottom edges in uneven drips (long fingers, short stubs, merging into a curtain). Farther down, that paint gradually turns into the given colors: first color nearest the letters, last at the tips, all swirled and marbled together, with streaks of letter-colored paint reaching down into them and bright ribbons where the paint folds. Black text drips black paint that turns into the colors; white text drips white. After the drips have run, the paint keeps flowing inside them (live).
React · Motion
"use client";
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { animate, useInView, useMotionValue, useReducedMotion } from "motion/react";
export interface PaintDripProps {
text: string;
/** Paint colors, from the color right under the letters to the color at the drip tips. */
colors?: string[];
/** Letter color. Defaults to the CSS color. */
color?: string;
/** Longest drip, em. */
length?: number;
/** How much the colors swirl and marble together, 0–2. */
turbulence?: number;
/** How far the paint stays the letter color before turning into `colors`, em. */
melt?: number;
/** Seconds for the drips to run down. */
duration?: number;
delay?: number;
/** Speed of the paint flowing inside the drips. */
speed?: number;
/** Keep the paint flowing after the drips have run. */
live?: boolean;
trigger?: "load" | "inView";
className?: string;
style?: CSSProperties;
}
const VERT = `#version 300 es
layout(location = 0) in vec2 aPos;
out 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 = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
// r: px below the lowest letter pixel in this column (0 inside letters; huge
// above the column's lowest letter pixel, so counters and gaps stay clean)
// b: letter coverage (anti-aliased)
uniform sampler2D uField;
uniform sampler2D uRamp;
uniform vec2 uRes; // canvas px
uniform float uFs; // font size, canvas px
uniform float uP; // drip progress 0..1
uniform float uTime;
uniform float uLen; // em
uniform float uTurb;
uniform float uMelt; // em
uniform float uMid; // canvas px, vertical centre of the text
uniform vec3 uColor;
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 noise1(float x) { return noise(vec2(x, 0.37)); }
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) { v += a * noise(p); p = p * 2.03 + 1.7; a *= 0.5; }
return v;
}
vec4 field(vec2 px) { return texture(uField, px / uRes); }
void main() {
vec2 p = vUv * uRes;
float fs = uFs;
float t = uTime;
vec4 f0 = field(p);
float grow = uP;
// Paint wanders sideways more the farther below the text it has run.
float below = p.y - uMid;
float amp = (0.05 + 0.13 * uTurb) * fs * smoothstep(0.1 * fs, 0.8 * fs, below);
float wx = (fbm(vec2(p.x / fs * 2.6, p.y / fs * 1.1 - t * 0.18)) - 0.5) * 2.0 * amp;
vec2 pw = p + vec2(wx, 0.0);
// Paint spreads a little sideways as it runs, merging neighbouring drips
// into a curtain.
float spread = 0.1 * fs * smoothstep(0.1 * fs, 0.6 * fs, below);
float d = min(field(pw).r,
min(field(pw + vec2(spread, 0.0)).r, field(pw - vec2(spread, 0.0)).r) + 0.25 * spread);
// Drip length per column: a long, slow wave plus narrow fingers.
float u = pw.x / fs;
float prof = 0.4 + 0.3 * noise1(u * 1.4 + 3.0) + 0.4 * pow(noise1(u * 4.8 + 11.0), 2.0)
+ 0.25 * pow(noise1(u * 10.0 + 2.0), 3.0) - 0.1 * noise1(u * 17.0 + 5.0);
prof = clamp(prof, 0.0, 1.0);
float L = uLen * fs * prof * grow * (1.0 + 0.03 * sin(t * 0.6 + u * 3.0));
float paint = (1.0 - smoothstep(L - 1.5, L + 0.5, d)) * step(0.001, grow);
// Colors: the ramp runs down the drips, marbled by domain-warped noise that
// flows downward, with vertical streaks.
float fr = d / (uLen * fs);
vec2 q = vec2(p.x / fs * 3.0, p.y / fs * 0.9 - t * 0.22);
vec2 w = vec2(fbm(q), fbm(q + vec2(5.2, 1.3)));
float s = fbm(q + 2.4 * uTurb * w + vec2(0.0, -t * 0.08));
float streak = noise(vec2(p.x / fs * 13.0, p.y / fs * 0.7 - t * 0.3));
float idx = fr * 0.95 + 0.12 + (s - 0.5) * 1.6 * uTurb + (streak - 0.5) * 0.35;
vec3 col = texture(uRamp, vec2(clamp(idx, 0.0, 1.0), 0.5)).rgb;
// Bright ribbons where the swirl folds (like streaks of the top color).
float vein = 1.0 - smoothstep(0.0, 0.018, abs(s - 0.58));
col = mix(col, texture(uRamp, vec2(0.18, 0.5)).rgb, vein * 0.75 * smoothstep(0.05, 0.25, fr));
// Wet shading: slightly darker toward the drip edges, a soft sheen.
float edge = smoothstep(L - 0.08 * fs, L, d);
col *= 1.0 - 0.3 * edge;
col += 0.08 * smoothstep(0.55, 0.8, streak) * (1.0 - edge);
// The paint leaving the letters is the letter color itself; farther down
// it gradually turns into the palette, along a marbled, flowing boundary
// (streaks of letter-colored paint reach down into the colors).
float m = d / (uMelt * fs) + (s - 0.5) * 1.2 * uTurb + (streak - 0.5) * 0.5;
float own = 1.0 - smoothstep(0.55, 1.15, m);
col = mix(col, uColor * (1.0 - 0.15 * edge), own);
vec4 outPaint = vec4(col * paint, paint);
// Letters stay solid.
vec3 letter = uColor;
float a = f0.b;
outColor = outPaint * (1.0 - a) + vec4(letter * a, a);
}`;
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];
}
// Field resolution cap (pixels); linear filtering covers the rest.
const MAX_FIELD_PIXELS = 900_000;
const FAR = 30000;
export function PaintDrip({
text,
colors = ["#fff3a0", "#ffcc1a", "#ff8c00", "#ff4500", "#e0112b", "#8a1a7a", "#3a1f8a", "#1a1660"],
color,
length = 4.6,
turbulence = 1,
melt = 0.85,
duration = 10,
delay = 1,
speed = 1,
live = true,
trigger = "load",
className,
style,
}: PaintDripProps) {
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, em: the drips need space below.
const padX = 0.4;
const padTop = 0.15;
const padBottom = length + 0.3;
useEffect(() => {
if (!started) return;
if (reduceMotion) {
progress.set(1);
return;
}
// Slow start, like paint gathering weight, then easing out as it runs.
const controls = animate(progress, 1, { duration, delay, ease: [0.55, 0, 0.35, 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("webgl2", { 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()!;
const vs = compile(gl.VERTEX_SHADER, VERT);
const fsh = compile(gl.FRAGMENT_SHADER, FRAG);
gl.attachShader(prog, vs);
gl.attachShader(prog, fsh);
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);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const u = (name: string) => gl.getUniformLocation(prog, name);
const textures = [0, 1].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;
});
gl.uniform1i(u("uField"), 0);
gl.uniform1i(u("uRamp"), 1);
// Paint 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);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, textures[1]);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, ramp);
// Draw the text, then per column measure how far each pixel is below the
// letters (where paint runs) and, inside letters, how far to their bottom.
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 + padX * 2 * fs) * dpr));
const h = Math.max(1, Math.round((rect.height + (padTop + padBottom) * fs) * dpr));
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
const s = Math.min(1, Math.sqrt(MAX_FIELD_PIXELS / (w * h)));
const sw = Math.max(1, Math.round(w * s));
const sh = Math.max(1, Math.round(h * s));
const mask = document.createElement("canvas");
mask.width = sw;
mask.height = sh;
const m = mask.getContext("2d", { willReadFrequently: true })!;
m.fillStyle = "#000";
m.fillRect(0, 0, sw, sh);
m.fillStyle = "#fff";
m.font = `${cs.fontStyle} ${cs.fontWeight} ${fs * dpr * s}px ${cs.fontFamily}`;
if ("letterSpacing" in m && cs.letterSpacing !== "normal") {
(m as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
`${parseFloat(cs.letterSpacing) * dpr * s}px`;
}
m.textAlign = "center";
m.textBaseline = "middle";
// Text box centre sits padTop em below the canvas top + half the box.
m.fillText(text, sw / 2, (padTop * fs + rect.height / 2) * dpr * s);
const px = m.getImageData(0, 0, sw, sh).data;
const data = new Float32Array(sw * sh * 4);
for (let x = 0; x < sw; x++) {
// Paint only leaves from the lowest letter pixel in each column.
let lowest = -1;
for (let y = sh - 1; y >= 0; y--) {
if (px[(y * sw + x) * 4] >= 128) {
lowest = y;
break;
}
}
for (let y = 0; y < sh; y++) {
const i = y * sw + x;
const a = px[i * 4] / 255;
data[i * 4] = a >= 0.5 ? 0 : lowest >= 0 && y > lowest ? (y - lowest) / s : FAR;
data[i * 4 + 2] = a;
}
}
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, textures[0]);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, sw, sh, 0, gl.RGBA, gl.FLOAT, data);
gl.uniform2f(u("uRes"), w, h);
gl.uniform1f(u("uFs"), fs * dpr);
gl.uniform1f(u("uMid"), (padTop * fs + rect.height / 2) * dpr);
gl.uniform3fv(u("uColor"), parseColor(color ?? cs.color));
dirty = true;
};
gl.uniform1f(u("uLen"), length);
gl.uniform1f(u("uTurb"), turbulence);
gl.uniform1f(u("uMelt"), melt);
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();
// Paint only flows once it's out (p > 0).
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) * speed);
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.deleteShader(vs);
gl.deleteShader(fsh);
gl.deleteProgram(prog);
};
}, [text, colors.join(), color, length, turbulence, melt, speed, live, padBottom, 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: `${-padX}em`,
top: `${-padTop}em`,
width: `calc(100% + ${padX * 2}em)`,
height: `calc(100% + ${padTop + padBottom}em)`,
pointerEvents: "none",
}}
/>
)}
</span>
);
}