How I derive sizes, width, and height from Sanity metadata to kill CLS

May 18, 2026 · 6 min read

Layout shift on images is almost always a width/height problem. When next/image doesn't know the intrinsic dimensions up front, the browser can't reserve space, and the page jumps once the image loads. Sanity stores exact pixel dimensions on every uploaded asset — in metadata.dimensions — so there's no excuse for guessing.

Why metadata.dimensions is the right source of truth

Every Sanity image asset has a metadata object written at upload time. The dimensions field gives you width, height, and aspectRatio as numbers. These never change unless someone re-uploads the file, so they're safe to project in GROQ and pass straight to <Image>.

The common mistake is projecting only asset._ref or the URL and then hard-coding dimensions in the component. The moment an editor swaps a landscape image for a portrait one, you get a wrong aspect ratio and CLS.

Here's the GROQ projection I use on every image field:

// Used inside any document query
image {
  asset->{
    url,
    metadata {
      dimensions {
        width,
        height,
        aspectRatio
      },
      lqip
    }
  },
  hotspot,
  crop
}

Project lqip while you're here — it's a base64 blur placeholder that costs nothing extra and improves perceived LCP. The hotspot and crop fields matter if you've implemented focal-point cropping, but the focus of this post is the dimension side.

Typing the response and building the image URL

With Sanity TypeGen you'd get this for free, but here's a manual type so the component stays self-contained:

// lib/sanity.image.ts
import imageUrlBuilder from '@sanity/image-url'
import { client } from './sanity.client'
 
const builder = imageUrlBuilder(client)
 
export interface SanityImageData {
  asset: {
    url: string
    metadata: {
      dimensions: { width: number; height: number; aspectRatio: number }
      lqip?: string
    }
  }
  hotspot?: { x: number; y: number }
  crop?: { top: number; bottom: number; left: number; right: number }
}
 
/**
 * Returns a Sanity image URL with format and quality set.
 * Width is added by the calling component via `.width(n)`.
 */
export function urlFor(source: SanityImageData) {
  return builder.image(source).auto('format').quality(80)
}
 
/**
 * Derives the rendered pixel dimensions after Sanity crop is applied.
 * Crop values are fractions (0–1). If no crop, returns the raw asset dimensions.
 */
export function croppedDimensions(image: SanityImageData): {
  width: number
  height: number
  aspectRatio: number
} {
  const { width, height } = image.asset.metadata.dimensions
  const crop = image.crop ?? { top: 0, bottom: 0, left: 0, right: 0 }
 
  const croppedWidth = Math.round(width * (1 - crop.left - crop.right))
  const croppedHeight = Math.round(height * (1 - crop.top - crop.bottom))
 
  return {
    width: croppedWidth,
    height: croppedHeight,
    aspectRatio: croppedWidth / croppedHeight,
  }
}

croppedDimensions matters because Sanity's crop field is applied client-side when you build the URL. If you pass the raw asset width/height to next/image but the URL has a crop applied, the aspect ratio will be wrong and you'll get CLS.

The responsive image component

Here's the full component I use for editorial images — hero banners, article body images, card thumbnails. It handles three common patterns: full-width, constrained, and fixed-size.

// components/SanityImage.tsx
import Image from 'next/image'
import { urlFor, croppedDimensions, type SanityImageData } from '@/lib/sanity.image'
 
interface Props {
  image: SanityImageData
  /**
   * Pass a `sizes` string that matches your CSS layout.
   * Defaults to full viewport width — override for constrained contexts.
   * Examples:
   *   "100vw"                          — full bleed
   *   "(min-width: 1280px) 1200px, 100vw" — max-width container
   *   "(min-width: 768px) 50vw, 100vw" — 2-col grid
   *   "320px"                          — fixed thumbnail
   */
  sizes?: string
  /** CSS class applied to the wrapper div that holds the aspect ratio */
  className?: string
  priority?: boolean
  alt: string
}
 
export function SanityImage({
  image,
  sizes = '100vw',
  className = '',
  priority = false,
  alt,
}: Props) {
  const { width, height } = croppedDimensions(image)
 
  // Build a srcset-ready URL. next/image calls the loader for each breakpoint.
  // We supply the full-resolution URL; next/image's built-in loader adds w= params.
  const src = urlFor(image).url()
 
  return (
    <div
      className={`relative overflow-hidden ${className}`}
      // Inline aspect-ratio prevents CLS even before the image src resolves.
      style={{ aspectRatio: `${width} / ${height}` }}
    >
      <Image
        src={src}
        alt={alt}
        fill
        sizes={sizes}
        placeholder={image.asset.metadata.lqip ? 'blur' : 'empty'}
        blurDataURL={image.asset.metadata.lqip}
        priority={priority}
      />
    </div>
  )
}

A few decisions worth explaining:

fill + aspect-ratio wrapper instead of explicit width/height — Using fill means next/image renders a position: absolute <img> that fills its parent. The parent has position: relative and an inline aspect-ratio. The browser can calculate the reserved space immediately from the CSS, so there's no shift. This is cleaner than passing numeric width/height to fill={false} because you don't need to know the rendered CSS pixel width at the call site — you just know the intrinsic ratio.

The sizes prop is the most important thing you control — next/image uses sizes to decide which srcset variant to download. If you lie here (or use the default 100vw for a 400 px thumbnail), the browser fetches a 1200 px image for a 400 px slot. Use real breakpoints. For a two-column card grid that goes full-width below 768 px:

sizes="(min-width: 768px) 50vw, 100vw"

For a max-width-1280 container:

sizes="(min-width: 1280px) 1200px, 100vw"

For a fixed 80 px avatar that never changes size:

sizes="80px"

Getting this wrong doesn't cause CLS, but it causes LCP regression and wastes bandwidth — which is why it belongs in the same conversation as dimension handling.

Checking your work in the browser

Open DevTools Network tab, filter by Img, and look at the w= parameter in the Sanity CDN URL next/image requests. It should be close to the actual rendered pixel width × device pixel ratio. If you see w=3840 for a 400 px card, your sizes is wrong. If the image jumps down when it loads, aspectRatio isn't being set — check that croppedDimensions is returning non-zero values and that the wrapper div has position: relative.

You can also run Lighthouse with CPU throttling and watch for CLS attributed to images. A correctly configured <SanityImage> should contribute 0 to CLS on any layout.

Handling fill-mode vs. fixed-size in the same schema field

Some layouts need a fixed pixel image (a logo, an icon, an avatar) rather than a fluid one. For those cases, skip the wrapper entirely and use numeric width/height on <Image fill={false}>:

// For fixed-size images where you know the rendered dimensions
const { width: intrinsicW, height: intrinsicH } = croppedDimensions(image)
 
// Scale down proportionally to the max rendered size
const maxW = 160
const scale = Math.min(1, maxW / intrinsicW)
const renderedW = Math.round(intrinsicW * scale)
const renderedH = Math.round(intrinsicH * scale)
 
;<Image
  src={urlFor(image).width(renderedW * 2).url()} // 2× for retina
  alt={alt}
  width={renderedW}
  height={renderedH}
/>

Passing width and height here tells both the browser and next/image exactly how much space to reserve. No wrapper needed.

The single rule to remember

Every <Image> in your app should have its reserved space derivable from static information — either inline width/height on the element, or a position: relative parent with an aspect-ratio set before the image loads. Sanity's metadata.dimensions gives you that static information for free. Project it in GROQ, pass it through your component, and you'll never debug image-induced CLS again.

Related posts

All posts →