Files
Santhosh Janardhanan ac3de3e142
Some checks failed
ci / site (push) Has been cancelled
publish-image / publish (push) Has been cancelled
lazy-loading done
2026-02-10 15:59:03 -05:00

5.9 KiB

Context

The site is a static Astro SSG with no framework islands — all client-side interactivity uses vanilla JS via <script is:inline> tags. Images appear in two surfaces:

  1. Card thumbnailsStandardCard.astro renders <img src={imageUrl} alt="" loading="lazy" /> inside a .card-media div. When there's no image, a static .card-placeholder div fills the space. The image area is fixed at 180px height (200px on mobile).
  2. Blog featured imagesblog/post/[slug].astro and blog/page/[slug].astro render <img loading="lazy" /> inline with a max-height of 420px.

Currently loading="lazy" defers fetching until the image nears the viewport, but the image area is blank/transparent until download completes, causing a jarring pop-in.

The site's dark theme (--bg0: #0b1020) means the shimmer must be a subtle light-on-dark gradient sweep — not a grey-on-white pattern.

Goals / Non-Goals

Goals:

  • Show an animated shimmer placeholder while images load, matching the site's dark aesthetic
  • Fade in the actual image smoothly once loaded
  • Handle image load errors gracefully (keep placeholder visible)
  • Respect prefers-reduced-motion (suppress shimmer animation)
  • Zero new dependencies — CSS keyframes + vanilla JS only

Non-Goals:

  • Generating low-quality image placeholders (LQIP/BlurHash) at build time — this requires a build pipeline change and image processing dependency
  • Lazy loading changes to the loading attribute strategy — loading="lazy" is already set and works correctly
  • Image format optimization (WebP/AVIF conversion) — separate concern
  • Placeholder for Instagram embeds — those use a third-party embed script with its own loading state

Decisions

1. CSS shimmer via @keyframes on a pseudo-element

Decision: The shimmer effect uses a CSS @keyframes animation on a ::before pseudo-element of the image wrapper. The pseudo-element displays a translucent gradient that sweeps left-to-right.

Rationale: Pure CSS, no JS needed for the animation. Pseudo-elements avoid extra DOM nodes. The existing .card-placeholder already fills the image area — the shimmer just adds motion on top of it.

Alternatives considered:

  • SVG animated placeholder: More complex markup, no visual benefit for a simple shimmer.
  • JS-driven animation (requestAnimationFrame): Heavier, no benefit over CSS keyframes.
  • <canvas> BlurHash: Requires a build-time hash generation pipeline — overkill for a shimmer.

2. Image starts hidden, fades in on load event

Decision: The <img> element renders with opacity: 0 (via a CSS class like .img-loading). A small inline <script> listens for the load event on each image and removes the loading class, triggering a CSS opacity transition to fade in. The shimmer placeholder sits behind the image and is naturally hidden once the image becomes opaque.

Rationale: The load event is the reliable signal that the image is ready to display. opacity transition is GPU-composited and smooth. No layout shift because the image dimensions are fixed by the container (.card-media img has height: 180px; object-fit: cover).

Implementation detail: Images may already be cached by the browser (instant load). The script must handle the case where img.complete && img.naturalWidth > 0 on page load — immediately remove the loading class without waiting for the load event.

Alternatives considered:

  • IntersectionObserver to defer src assignment: Already handled by loading="lazy" — doubling up adds complexity for no benefit.
  • CSS-only :not([src]) or content-visibility: No reliable CSS-only way to detect "image has loaded."

3. Single reusable wrapper pattern

Decision: Wrap each <img> in a container element with class .img-shimmer-wrap. This wrapper gets the shimmer pseudo-element and positions the image on top. The same wrapper pattern is used for both card thumbnails and blog featured images.

Rationale: One CSS class, one script, works everywhere. The wrapper inherits the size from its parent (.card-media for cards, inline styles for blog images), so no per-surface sizing logic is needed.

4. Error handling: keep placeholder visible

Decision: On image error event, the loading class stays on (image remains opacity: 0), so the shimmer/placeholder remains visible. The shimmer animation stops (replaced by a static placeholder state) to avoid an endlessly-animating error state.

Rationale: A static placeholder is better UX than a broken image icon or an infinite shimmer. The user sees a clean grey block instead of a broken experience.

5. Reduced motion: static placeholder, no shimmer sweep

Decision: The existing global CSS rule (@media (prefers-reduced-motion: reduce)) already suppresses all animations and transitions to near-zero duration. The shimmer animation inherits this behavior automatically — no additional media query needed.

Rationale: The global rule (animation-duration: 0.001ms !important) already covers all keyframe animations. The shimmer becomes a static gradient overlay, which is still a valid loading indicator without motion.

Risks / Trade-offs

  • [Layout shift on blog images] → Blog featured images use inline max-height: 420px but no explicit width/height attributes, which could cause minor CLS. Mitigation: the wrapper inherits width: 100% and max-height: 420px from the existing inline styles, so the placeholder reserves the correct space. No change in CLS from current behavior.
  • [Already-cached images flash shimmer] → If images are in the browser cache, they load near-instantly, but the shimmer might flicker briefly. Mitigation: the script checks img.complete on DOM ready and immediately reveals cached images — no visible shimmer for cached images.
  • [Increased CSS size] → The shimmer keyframes and wrapper styles add ~30 lines to global.css. Mitigation: negligible impact on a static site with a single CSS file.