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:
- Card thumbnails —
StandardCard.astrorenders<img src={imageUrl} alt="" loading="lazy" />inside a.card-mediadiv. When there's no image, a static.card-placeholderdiv fills the space. The image area is fixed at 180px height (200px on mobile). - Blog featured images —
blog/post/[slug].astroandblog/page/[slug].astrorender<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
loadingattribute 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:
IntersectionObserverto defersrcassignment: Already handled byloading="lazy"— doubling up adds complexity for no benefit.- CSS-only
:not([src])orcontent-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: 420pxbut no explicitwidth/heightattributes, which could cause minor CLS. Mitigation: the wrapper inheritswidth: 100%andmax-height: 420pxfrom 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.completeon 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.