A Fully-Local Placeholder Image Generator — No More Blocking via.placeholder.com
Placeholder image services go down, get rate-limited, or get blocked by corporate firewalls. And they're an unnecessary external dependency when Canvas can generate the same image in milliseconds, entirely in your browser. This tool gives you sized placeholders with colored backgrounds, 6 patterns, and PNG download — without touching a network.
Anyone who's built a UI with placeholder images has been burned by the hosted service going down. via.placeholder.com, placeholder.com, placehold.it — they all have uptime issues. The solution is obvious in retrospect: draw the image locally.
🔗 Live demo: https://sen.ltd/portfolio/placeholder-img/
📦 GitHub: https://github.com/sen-ltd/placeholder-img
Features:
- Custom dimensions (W × H) or presets
- 6 patterns: solid, checkerboard, grid, diagonal stripes, noise, gradient
- Background and text color pickers
- Custom text (default: dimensions)
- Drag-to-resize preview
- Bulk download (generate a set of sizes at once)
- PNG download
- Japanese / English UI
- Zero dependencies, 32 tests
Contrast-aware text color
When the user picks a background color, the text should stay readable. Auto-choose white or black based on luminance:
export function getBrightness(hex) {
const { r, g, b } = parseHex(hex);
// Standard luminance formula
return Math.round((r * 299 + g * 587 + b * 114) / 1000);
}
export function contrastingColor(hex) {
return getBrightness(hex) > 128 ? '#000000' : '#ffffff';
}
The 299/587/114 coefficients come from NTSC luminance (approximation of human perception). 128 is the midpoint; above it the background is "bright" and black text reads better, below it the background is "dark" and white text reads better.
Proportional font size
A 32×32 favicon and a 1920×1080 banner both need readable text, but not the same font size. Scale by the smaller dimension:
export function calculateFontSize(width, height, ratio = 0.15) {
return Math.max(10, Math.round(Math.min(width, height) * ratio));
}
ratio = 0.15 means the text height is 15% of the smaller dimension. A 32×32 image gets 5px (clamped to 10), a 1920×1080 gets 162px. The minimum of 10px prevents unreadable tiny text on small placeholders.
Pattern: checkerboard
export function drawCheckerboard(ctx, w, h, size, color1, color2) {
for (let y = 0; y < h; y += size) {
for (let x = 0; x < w; x += size) {
const even = ((x / size) + (y / size)) % 2 === 0;
ctx.fillStyle = even ? color1 : color2;
ctx.fillRect(x, y, size, size);
}
}
}
The sum of cell coordinates being even or odd determines the color — same trick as a chessboard. For a 400×300 image with size=20, that's 300 cells total, each rendered in a single fillRect call. Milliseconds.
Pattern: diagonal stripes
export function drawDiagonalStripes(ctx, w, h, size, color1, color2) {
ctx.fillStyle = color1;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = color2;
// Draw stripes as parallelograms shifted along the diagonal
for (let offset = -h; offset < w + h; offset += size * 2) {
ctx.beginPath();
ctx.moveTo(offset, 0);
ctx.lineTo(offset + size, 0);
ctx.lineTo(offset + size + h, h);
ctx.lineTo(offset + h, h);
ctx.closePath();
ctx.fill();
}
}
Each stripe is a parallelogram. Start at offset on the top edge, go right by size, go down to h (with the same x-offset) — that's one stripe. Repeat every size * 2 pixels (stripe + gap = 2 × size).
Bulk generation
The killer feature for practical use: generate a full set of placeholder images at once. Needs a 400×400 Instagram, a 1200×630 OGP, and a 1280×720 YouTube thumbnail? Click "Social Media set" and get a ZIP of all three.
Actually, creating a ZIP requires a library. The simpler version: trigger multiple <a download> clicks in sequence:
async function bulkDownload(presets, options) {
for (const preset of presets) {
const canvas = document.createElement('canvas');
canvas.width = preset.w;
canvas.height = preset.h;
drawPlaceholder(canvas, { ...options, width: preset.w, height: preset.h });
const url = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = url;
a.download = `placeholder-${preset.w}x${preset.h}.png`;
a.click();
await new Promise(r => setTimeout(r, 100));
}
}
The setTimeout(100) gives the browser time to register each download before the next one starts. Without it, browsers might deduplicate or cancel rapid downloads.
Series
This is entry #85 in my 100+ public portfolio series.
- 📦 Repo: https://github.com/sen-ltd/placeholder-img
- 🌐 Live: https://sen.ltd/portfolio/placeholder-img/
- 🏢 Company: https://sen.ltd/

Top comments (0)