400-Byte Image Previews: Blur-Up Placeholders with PHP GD
Between "empty gray rectangle" and "image loaded" there's a moment - long on hotel wifi - where your page is a wireframe of voids. The trick used by every polished image-heavy site: ship a preview so small it's free, inline in the HTML itself, and let the real image fade in over it. The preview generator is pure GD, run once at upload:
function makePlaceholder(string $sourcePath, int $width = 24): string
{
[$w, $h] = getimagesize($sourcePath);
$height = max(1, (int) round($h * $width / $w));
$src = imagecreatefromstring(file_get_contents($sourcePath));
$tiny = imagecreatetruecolor($width, $height);
imagecopyresampled($tiny, $src, 0, 0, 0, 0, $width, $height, $w, $h);
ob_start();
imagejpeg($tiny, null, 55); // quality is irrelevant at 24px
return 'data:image/jpeg;base64,' . base64_encode(ob_get_clean());
}
A 24px-wide JPEG is 300-600 bytes - store the data URI in the media table next to the file (that's a column, not a file), and it costs one INSERT forever. (Note the output-buffer capture - GD only writes to streams, so we bottle it.)
The markup and the crossfade
<div class="blur-frame" style="aspect-ratio: 3 / 2">
<img class="placeholder" src="data:image/jpeg;base64,..." alt="" aria-hidden="true">
<img class="real" src="/storage/media/photo.webp" alt="Workshop bench"
loading="lazy" width="1200" height="800"
onload="this.classList.add('loaded')">
</div>
.blur-frame { position: relative; overflow: hidden; border-radius: 12px; }
.blur-frame img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.placeholder {
filter: blur(18px);
transform: scale(1.08); /* hide the blur's soft edges */
}
.real {
opacity: 0;
transition: opacity 0.35s ease;
}
.real.loaded { opacity: 1; }
The blur does the magic: 24 pixels of data, smeared by the GPU into a plausible impression of the image - colors, composition, light. The scale(1.08) crops off the blur's transparent fringe. When the real image (still lazy-loaded, still CLS-safe via aspect-ratio and dimensions) arrives, it fades over the preview. Cached repeat views fire onload immediately, so there's no gratuitous animation for returning visitors - the fade only exists when there was actually a wait.
Taste and bookkeeping
- Reserve it for images that matter - heroes, product galleries, cards above the fold. Tiny thumbnails don't need placeholders for their placeholders.
- Regenerate the data URI when the file is replaced at upload time - stale previews of old images are uniquely disorienting.
- The inline URIs add to HTML weight; at ~400 bytes each, twenty of them cost 8KB - cheaper than one spinner GIF, but still a budget. Cap how many you inline per page.
- data:image/jpeg even when the real file is WebP - the placeholder format is independent, and tiny JPEGs compress this size class best.
Total system: one GD function, one column, ten lines of CSS. It's the highest polish-per-byte feature I know, and it makes slow connections feel designed for instead of ignored.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.