Ink bleed
Wanted that look of ink pressed into paper, where the edges bleed and tear and it feels a bit wet. So I built it from the ground up.
The word is stamped and then broken up with layers of noise: the edge creeps outward into the paper, a rough cut turns it into solid ink blobs with gaps, and finer noise tears the edges. The ink slowly creeps on its own, and moving over it spreads the ink wetter under your cursor. Type your own word and pick the ink and paper below.
Playground
Word
Bleed
Ink
Code
// Ink Bleed presets + Remix. Each preset is a tinted ink on a suited paper, with its own
// bleed amount / distortion / grain. Remix keeps the stamp-and-bleed mechanic but rolls a
// fresh ink colour on a matching paper and varies the amount + wetness.
import { INK_DEFAULTS, type InkParams } from "./engine";
export type InkPreset = { id: string; name: string; params: Partial<InkParams> };
export const INK_PRESETS: InkPreset[] = [
{
id: "black",
name: "Black",
// classic black ink on warm off-white paper (the default)
params: { ink: [0.08, 0.07, 0.07], paper: [0.95, 0.93, 0.87], amount: 0.55, distort: 0.5, grain: 0.18 },
},
{
id: "oxblood",
name: "Oxblood",
// deep red ink on cream
params: { ink: [0.42, 0.08, 0.09], paper: [0.96, 0.93, 0.85], amount: 0.62, distort: 0.55, grain: 0.2 },
},
{
id: "indigo",
name: "Indigo",
// dark blue ink on cool paper
params: { ink: [0.1, 0.12, 0.38], paper: [0.92, 0.94, 0.96], amount: 0.5, distort: 0.45, grain: 0.16 },
},
{
id: "sepia",
name: "Sepia",
// warm brown ink on aged paper
params: { ink: [0.32, 0.2, 0.09], paper: [0.94, 0.89, 0.78], amount: 0.58, distort: 0.5, grain: 0.22 },
},
{
id: "forest",
name: "Forest",
// deep green ink on pale paper
params: { ink: [0.09, 0.28, 0.16], paper: [0.93, 0.95, 0.89], amount: 0.6, distort: 0.6, grain: 0.18 },
},
{
id: "whiteout",
name: "White",
// white ink on near-black paper (the "white ink" side of the set)
params: { ink: [0.94, 0.93, 0.9], paper: [0.09, 0.09, 0.1], amount: 0.55, distort: 0.5, grain: 0.16 },
},
];
export function defaultInkParams(): InkParams {
return { ...INK_DEFAULTS, ...INK_PRESETS[0].params };
}
// rgb 0..1 <-> #hex for the ColorControl (which speaks hex)
export const toHex = (c: [number, number, number]) =>
"#" + c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("");
export const fromHex = (h: string): [number, number, number] => {
const n = h.replace("#", "");
return [0, 2, 4].map((i) => parseInt(n.slice(i, i + 2) || "0", 16) / 255) as [number, number, number];
};
// a saturated-but-dark ink hue (reads as real ink, never a pastel)
function inkHue(hue: number): [number, number, number] {
const c = 1;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const seg = [
[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x],
][Math.floor(hue / 60) % 6];
// darken toward ink: scale down + a little floor so it isn't pure black
return [seg[0] * 0.42 + 0.05, seg[1] * 0.42 + 0.05, seg[2] * 0.42 + 0.05];
}
// Remix: a fresh ink colour + a paper tinted the OPPOSITE way (warm ink -> cool paper),
// with varied bleed amount / wetness / grain.
export function remixInkParams(): Partial<InkParams> {
const h = Math.random() * 360;
const ink = inkHue(h);
// a light paper faintly tinted toward the complementary hue so ink + sheet feel paired
const pc = inkHue((h + 180) % 360);
const pm = Math.max(pc[0], pc[1], pc[2], 0.001);
const paper: [number, number, number] = [
0.9 + (pc[0] / pm) * 0.06,
0.9 + (pc[1] / pm) * 0.06,
0.9 + (pc[2] / pm) * 0.06,
];
return {
ink,
paper,
amount: 0.4 + Math.random() * 0.4, // 0.4 - 0.8
distort: 0.35 + Math.random() * 0.4, // 0.35 - 0.75
grain: 0.12 + Math.random() * 0.14, // 0.12 - 0.26
};
}
Credits
CompanyStudy
DateJul 14, 2026
TagsWebGL, Ink, Noise
MIT → free to copy