Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

400-Byte Image Previews: Blur-Up Placeholders with PHP GD

C
Carlo Fontanos
· 3 min read

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.

C
Written by Carlo Fontanos

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.

Message sent!

Your details are only used to reply to you.

Keep reading