A tiny blurred version of each photo (a 20 px JPEG, ~0.7 KB inlined) sits in a box of the right shape until the real photo has decoded, then the photo fades in. No grey boxes, no pop, no layout shift.
(re-requests the photos with a 1.5 s delay so you can watch the blur clear)
Each placeholder is a 20 px JPEG inlined as a data URI (~0.7 KB). The box keeps the photo's ratio, so nothing moves when it lands.
Every photo below the hero: galleries, work photos, team, review photos
What it does
The figure carries the aspect ratio and the inline LQIP as its background (blurred by a pseudo-element, so the blur never touches the photo). The img starts at opacity 0 only when JS is on; JS waits for load + img.decode() before fading it in, so the fade never shows a half-painted image. Cached photos show instantly; a failed photo shows its alt text instead of a blur forever.
When to use
Any lazy-loaded photo. Make the LQIPs at build time (one line, below).
Pair with Gallery lightbox and Photo review cards.
Don't
On the LCP hero: give it fetchpriority="high" and no fade (a fading LCP delays it).
Placeholders bigger than ~1 KB each (20 px wide is plenty).
Reduced motion, no JS, accessibility
Reduced motion: the photo appears without the fade.
No JS: photos load normally over the placeholder (they're never hidden).
Alt text stays on the real img; the placeholder is a background (ignored by screen readers).
Notes
Make an LQIP with Pillow: im.thumbnail((20,20)); im.save(buf,'JPEG',quality=40) then base64 → data:image/jpeg;base64,… (see tools/resize.py). The builder's photo pipeline can emit it next to each photo.