Skip to content

perf/ux: blog post hero image has no width/height, causing layout shift on every post page load #1254

Description

@duyet

Severity: medium · Effort: S · Aspect: ux

Goal

Blog post hero images should render at a reserved, predictable box size so the page does not visibly jump (CLS) while the image loads.

Context & Why

Every blog post page (https://blog.duyet.net/{year}/{month}/{slug}) renders its hero image via -post-hero.tsx with loading="eager" (i.e. it is intentionally not lazy-loaded, because it's above the fold) but no width/height attributes and no aspect-ratio reservation in its class list. Because the browser doesn't know the image's intrinsic size until it downloads, the surrounding header content (byline, reading time, TOC controls) visibly reflows/jumps once the image loads — a textbook Cumulative Layout Shift regression, and one an end user on a slow connection will directly experience as page "jumping" while reading. This affects every single post that has a post.thumbnail, i.e. most of the blog's traffic-driving pages.

Evidence

  • apps/blog/src/routes/$year/$month/-post-hero.tsx:53-60:
    {post.thumbnail && (
      ``<img·      src={post.thumbnail}·      alt={post.title}·      loading="eager"·      className="relative left-1/2 -translate-x-1/2 block w-[92vw] max-w-[1024px] max-h-[88vh] object-contain my-[30px] rounded-[var(--rd-r)]"·    />``
    )}
    No width, height, or CSS aspect-ratio is set — the browser has zero size hint before the image downloads.
  • Contrast with apps/blog/components/MdxComponents.tsx:64-71, which handles in-body images and at least wraps them with loading="lazy" (deferring the shift until scroll, though it too lacks explicit dimensions) — the hero is worse because it's eager/above-the-fold, where CLS is most visible and most heavily weighted by Core Web Vitals.
  • apps/blog/CLAUDE.md confirms: "Images are served unoptimized (static SPA, no image optimization server)" — so there's no build-time image-processing step that could inject dimensions automatically; they must be set explicitly or the frontmatter/data layer must supply them.

Suggested approach

If post frontmatter/data (post.thumbnail) doesn't already carry known dimensions, either (a) probe/store the image's natural width/height at build time (in the existing prebuild pipeline that already processes _posts/) and pass them through as width/height props, or (b) reserve space purely in CSS with a fixed aspect-ratio on the wrapping element sized to the common thumbnail aspect ratio, accepting minor letterboxing for outliers. Apply the same treatment to apps/blog/components/home/FeaturedPost.tsx:33 and apps/blog/components/post/FeaturedPost.tsx:27, which look like they render the same kind of hero/featured image without explicit dimensions either.

Validation

Run a Lighthouse/PageSpeed Insights audit against a real post URL (e.g. any https://blog.duyet.net/{year}/{month}/{slug} with a thumbnail) on a throttled connection, and confirm the CLS metric drops to ~0 for that page, with no visible header reflow once the hero image finishes loading.


Filed by automated multi-repo audit (2026-07-16) — aspect: ux (UI/UX and accessibility).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions