Building a Placeholder Image Generator with Canvas API: From Basics to Performance
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