DEV Community

SEN LLC
SEN LLC

Posted on

A Fully-Local Placeholder Image Generator — No More Blocking via.placeholder.com

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

Screenshot

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';
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)