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