Kill CLS on Sanity Images: sizes, width & height from Metadata

By Nayan Kyada · · 6 min read

Part of The Sanity + Next.js Guide

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.

Frequently asked questions

What is Sanity's metadata.dimensions field and why does it prevent CLS?

Every Sanity image asset stores a `metadata` object at upload time, and `metadata.dimensions` gives you exact `width`, `height`, and `aspectRatio` numbers that never change unless the file is re-uploaded. Projecting it in GROQ and feeding it into an `aspect-ratio` CSS box (or explicit `width`/`height` on `next/image`) lets the browser reserve the correct space before the image loads, which is what eliminates layout shift.

What TypeScript type does Sanity TypeGen generate for image metadata (ImageAssetMetadata)?

If you project `asset->{ metadata { dimensions, lqip } }` and run `sanity typegen generate`, the resulting type matches Sanity's underlying `sanity.imageMetadata` document shape (commonly referenced as `ImageAssetMetadata` in the generated output). Prefer that generated type over hand-writing the `SanityImageData` interface shown in this post — TypeGen keeps it accurate automatically as the schema evolves.