Building a Graffiti Text Generator With Canvas: FontFace Loading, Seeded Spray Effects and PNG Export
Text-to-image tools are a fun canvas project with a surprising number of gotchas: fonts that haven't loaded yet, text that overflows, effects that "flicker" on every re-render, and clipboard APIs that behave differently
Text-to-image tools are a fun canvas project with a surprising number of gotchas: fonts that haven't loaded yet, text that overflows, effects that "flicker" on every re-render, and clipboard APIs that behave differently on every browser.
Graffiti Generator turns a word into spray-painted graffiti art — pick a font and colors, then download a PNG or copy it to the clipboard. It all runs in the browser with no upload and no account. Here's how the pieces fit together.
1. Load fonts explicitly with the FontFace API
Drawing on canvas with a web font that hasn't loaded silently falls back to a default font. Loading fonts explicitly avoids that:
const FONTS = {
"Permanent Marker": "/fonts/PermanentMarker.woff2",
"Rock Salt": "/fonts/RockSalt.woff2",
"Bungee Shade": "/fonts/BungeeShade.woff2",
"Bangers": "/fonts/Bangers.woff2",
};
async function ensureFont(name) {
if ([...document.fonts].some((f) => f.family === name && f.status === "loaded")) return;
const face = new FontFace(name, `url(${FONTS[name]}) format("woff2")`);
await face.load();
document.fonts.add(face);
}
All the fonts are open-source (Apache 2.0 or SIL OFL), self-hosted as WOFF2 — worth checking licenses if users will sell merch made with the output.
2. Fit text to the canvas
The canvas is a fixed 800×600. Long strings shrink to fit about 90% of the width, and input is capped at 30 characters so strokes don't become hairlines:
function fitFontSize(ctx, text, family, maxWidth, start = 200) {
let size = start;
do {
ctx.font = `${size}px "${family}"`;
if (ctx.measureText(text).width <= maxWidth) return size;
size -= 4;
} while (size > 12);
return size;
}
Multi-word text wraps onto up to three lines with tighter line spacing.
3. Draw in layers: outline, fill, shadow
Order matters. Stroke first with round joins so thick outlines don't produce spiky corners, then fill on top:
function drawText(ctx, text, { fill, outline, outlineWidth, shadow }) {
ctx.lineJoin = "round";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (shadow) {
ctx.shadowColor = "rgba(0,0,0,.5)";
ctx.shadowOffsetX = 6; ctx.shadowOffsetY = 6; ctx.shadowBlur = 12;
}
ctx.lineWidth = outlineWidth * 2; // stroke is centered on the path
ctx.strokeStyle = outline;
ctx.strokeText(text, 400, 300);
ctx.shadowColor = "transparent";
ctx.fillStyle = fill;
ctx.fillText(text, 400, 300);
}
4. A seeded spray layer (so the preview doesn't flicker)
The spray-paint look comes from speckles scattered near the letters. With Math.random(), every slider tweak would reshuffle the dots and the preview would "boil". A seeded PRNG keeps the same settings producing the same speckle placement:
function spray(ctx, rng, intensity, color) {
const { data } = ctx.getImageData(0, 0, 800, 600);
ctx.fillStyle = color;
const count = Math.floor(intensity * 4000);
for (let i = 0; i < count; i++) {
const x = Math.floor(rng() * 800), y = Math.floor(rng() * 600);
const alpha = data[(y * 800 + x) * 4 + 3];
if (alpha > 0 && alpha < 255) { // near glyph edges
const r = rng() * 1.5 + 0.3;
ctx.beginPath(); ctx.arc(x + (rng() - .5) * 8, y + (rng() - .5) * 8, r, 0, Math.PI * 2); ctx.fill();
}
}
}
The intensity slider simply controls how many dots are attempted.
5. Export: download and clipboard
Transparent backgrounds keep their alpha channel, so the PNG can sit on top of photos:
async function exportPng(canvas, { copy = false } = {}) {
const blob = await new Promise((r) => canvas.toBlob(r, "image/png"));
if (copy && navigator.clipboard && window.ClipboardItem) {
try {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
return "copied";
} catch { /* fall through to download */ }
}
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "graffiti.png";
a.click();
URL.revokeObjectURL(a.href);
return "downloaded";
}
Clipboard image support varies (iOS Safari in particular), so a download fallback is essential.
6. Be clear about what it isn't
The output is a flat 800×600 PNG, not a font and not copy-pasteable Unicode text. That distinction matters for users: it works as an Instagram story sticker or Discord image, but not inside a bio or username field. Stating the limits up front (no SVG export, fixed size, procedural spray rather than photo textures) saves a lot of confused feedback.
Takeaways
- Load web fonts explicitly before drawing to canvas.
- Measure and shrink text to fit; cap input length.
- Use seeded randomness for procedural effects so previews are stable.
- Always provide a download fallback for clipboard image copy.
Try it at graffitigenerator.net. What canvas gotchas have you run into?
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.