Building a Placeholder Image Generator with Canvas API: From Basics to Performance#

As a frontend developer, I often need placeholder images: design assets aren’t ready, images are missing, or I’m building a quick prototype. I used to rely on services like placehold.co or picsum.photos, but network issues or specific style requirements led me to build my own.

Canvas API Basics#

The core is the Canvas API. Here’s the simplest implementation:

const canvas = document.createElement('canvas')
canvas.width = 400
canvas.height = 300

const ctx = canvas.getContext('2d')

// Fill background
ctx.fillStyle = '#374151'
ctx.fillRect(0, 0, 400, 300)

// Draw text
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 48px system-ui'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('400 × 300', 200, 150)

This generates a 400x300 gray placeholder. But real projects need more.

Dynamic Font Size Calculation#

Since users define image dimensions, font size can’t be hardcoded. My approach:

function calculateFontSize(width: number, height: number): number {
  const minSize = 12
  const maxSize = 200
  const baseSize = Math.min(width, height) / 8
  
  return Math.max(minSize, Math.min(maxSize, baseSize))
}

Why Math.min(width, height) instead of just width or height? A 1200x90 banner with width / 8 gives 150px font—too tall for a 90px image.

Another issue: variable text length. Users might enter “Loading…” or “Avatar Placeholder”. Need to adjust font based on text:

function fitText(
  ctx: CanvasRenderingContext2D,
  text: string,
  maxWidth: number,
  maxHeight: number
): number {
  let fontSize = calculateFontSize(maxWidth, maxHeight)
  ctx.font = `bold ${fontSize}px system-ui`
  
  while (ctx.measureText(text).width > maxWidth * 0.9 && fontSize > 12) {
    fontSize -= 2
    ctx.font = `bold ${fontSize}px system-ui`
  }
  
  return fontSize
}

measureText returns precise text width at current font—key for accurate sizing.

Grid Background Implementation#

Solid backgrounds are boring. A grid adds design flair:

function drawGrid(
  ctx: CanvasRenderingContext2D,
  width: number,
  height: number,
  gridSize: number,
  color: string
) {
  ctx.strokeStyle = color
  ctx.lineWidth = 1
  
  // Vertical lines
  for (let x = 0; x <= width; x += gridSize) {
    ctx.beginPath()
    ctx.moveTo(x, 0)
    ctx.lineTo(x, height)
    ctx.stroke()
  }
  
  // Horizontal lines
  for (let y = 0; y <= height; y += gridSize) {
    ctx.beginPath()
    ctx.moveTo(0, y)
    ctx.lineTo(width, y)
    ctx.stroke()
  }
}

Performance detail: beginPath() and stroke() call frequency. My initial code:

// ❌ Poor performance
for (let x = 0; x <= width; x += gridSize) {
  ctx.beginPath()
  ctx.moveTo(x, 0)
  ctx.lineTo(x, height)
  ctx.stroke()
}

Each loop calls stroke(). On a 2000x2000 canvas, 100 lines = 100 draw calls. Batch drawing:

// ✅ Better performance
ctx.beginPath()
for (let x = 0; x <= width; x += gridSize) {
  ctx.moveTo(x, 0)
  ctx.lineTo(x, height)
}
ctx.stroke()  // Single call

But Canvas 2D API doesn’t support discontinuous paths, so vertical and horizontal lines need separate batches. Testing showed 200 lines dropped from 15ms to 3ms.

Color Handling and Transparency#

Users select colors like #374151, but grid lines need transparency. Initially:

ctx.strokeStyle = textColor + '40'  // #ffffff40

This works mostly, but #fff becomes #fff40, which Canvas can’t parse.

Better approach with rgba:

function hexToRgba(hex: string, alpha: number): string {
  const r = parseInt(hex.slice(1, 3), 16)
  const g = parseInt(hex.slice(3, 5), 16)
  const b = parseInt(hex.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

ctx.strokeStyle = hexToRgba(textColor, 0.25)

Or use globalAlpha:

ctx.globalAlpha = 0.25
ctx.strokeStyle = textColor
// Draw grid...
ctx.globalAlpha = 1  // Reset

Image Download Implementation#

After Canvas generation, provide download. Core is toDataURL:

function downloadCanvas(canvas: HTMLCanvasElement, filename: string) {
  const url = canvas.toDataURL('image/png')
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
}

Several pitfalls:

1. Memory Leaks#

document.body.appendChild(a) creates an <a> element. If not removed, it stays in DOM. Not critical, but good practice to clean up.

2. Image Format Choice#

toDataURL supports image/png, image/jpeg, image/webp. PNG is lossless but larger; JPEG is lossy but smaller. For placeholders, PNG keeps text edges sharp.

For JPEG, specify quality:

canvas.toDataURL('image/jpeg', 0.9)  // 90% quality

3. CORS Restrictions#

If Canvas draws cross-origin images, toDataURL throws:

Uncaught DOMException: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.

Placeholder generators don’t use external images, so no issue. But for background image support, set crossOrigin:

const img = new Image()
img.crossOrigin = 'anonymous'
img.src = 'https://example.com/bg.jpg'

Preset Dimensions Design#

Common sizes as presets for quick selection:

const PRESETS = [
  { name: 'Avatar', width: 200, height: 200 },
  { name: 'Thumbnail', width: 300, height: 200 },
  { name: 'Card Image', width: 400, height: 300 },
  { name: 'Social Post', width: 500, height: 500 },
  { name: 'Banner', width: 728, height: 90 },
  { name: 'Hero Image', width: 1200, height: 630 },
]

These aren’t random—they’re based on real use cases:

  • Avatar (200x200): Social media standard
  • Banner (728x90): Google AdSense leaderboard
  • Hero Image (1200x630): Facebook/Open Graph recommended size

Performance Optimization Summary#

Building the Placeholder Image Generator, I identified key optimizations:

Optimization Method Result
Batch drawing Reduce stroke() calls 80% faster rendering
Font calculation Pre-calculate and cache Avoid repeated measuring
Size limits Max 2000px Prevent memory overflow
Debounce 300ms input delay Avoid frequent redraws

Final Result#

Tool: Placeholder Image Generator

Features:

  • Custom dimensions (1-2000px)
  • Custom background and text colors
  • Optional text content
  • 6 common presets
  • Grid background for visual appeal
  • One-click PNG download

Implementation isn’t complex, but getting details right takes effort. Hope this helps.


Related: Image Compression Tool | Base64 Image Encoder