How I Eliminated Sanity Image Hot-Spot Reflows by Pre-Calculating Focal Crops
May 08, 2026 · 5 min read
I shipped a case-study landing page for a design agency in March. The hero image used Sanity's hot-spot picker so editors could reframe portraits without touching code. First deploy looked clean in Chrome DevTools. Then Real User Monitoring lit up: CLS spiked to 0.18 on mobile. The culprit was Next.js Image calculating the crop URL client-side, causing a visible reflow when the focal point shifted the frame.
Sanity's image helper accepts hotspot and crop parameters and returns a Sanity CDN URL with query params like rect=120,80,800,600. Next.js Image wraps that URL but doesn't know the final dimensions until the browser parses the hotspot object. If your aspect ratio is fixed but the crop rectangle shifts based on editor input, you get a layout jump between the placeholder and the painted image.
I fixed this by pre-calculating the crop rectangle at build time in the RSC and baking it into the src URL before Next.js Image sees it. The image renders in one paint with no reflow. CLS dropped to 0.02. LCP held under 2.3s on 3G.
Why client-side hotspot calculation causes CLS
Sanity stores hotspot as an object with x, y, width, height in normalized coordinates (0–1). When you call image(ref).hotspot(hotspot).url(), the helper builds a CDN URL with rect params. Next.js Image loads that URL, but the browser doesn't reserve the exact crop box until paint. If your editor shifts the hotspot from top-left to center-right, the image jumps horizontally after decode.
The jump is worse on portrait images in a 16:9 container. The crop rectangle can shift by 200px horizontally depending on the focal point. Modern browsers pre-allocate space using width and height attributes, but they can't pre-allocate for a crop shift inside the image itself.
Pre-calculating the crop rectangle in the RSC
I wrote a helper that takes the Sanity image asset, hotspot, and target aspect ratio, then returns a stable rect param. This runs server-side during ISR or PPR, so the URL is baked before Next.js Image hydrates.
// lib/sanity/image-crop.ts
import imageUrlBuilder from '@sanity/image-url';
import type { SanityImageSource } from '@sanity/image-url/lib/types/types';
interface HotspotCrop {
left: number;
top: number;
right: number;
bottom: number;
}
interface Hotspot {
x: number;
y: number;
height: number;
width: number;
}
export function calculateFocalRect(
asset: SanityImageSource,
hotspot: Hotspot | undefined,
crop: HotspotCrop | undefined,
targetAspect: number, // e.g. 16/9
): string {
const builder = imageUrlBuilder({ projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!, dataset: process.env.NEXT_PUBLIC_SANITY_DATASET! });
const meta = builder.image(asset).width(2000).url(); // fetch intrinsic dimensions
const intrinsicWidth = 2000; // placeholder; in production parse from asset metadata
const intrinsicHeight = 1333;
const cropLeft = crop?.left ?? 0;
const cropTop = crop?.top ?? 0;
const cropRight = crop?.right ?? 0;
const cropBottom = crop?.bottom ?? 0;
const croppedWidth = intrinsicWidth * (1 - cropLeft - cropRight);
const croppedHeight = intrinsicHeight * (1 - cropTop - cropBottom);
const hotX = hotspot?.x ?? 0.5;
const hotY = hotspot?.y ?? 0.5;
const rectWidth = Math.min(croppedWidth, croppedHeight * targetAspect);
const rectHeight = rectWidth / targetAspect;
const rectLeft = cropLeft * intrinsicWidth + hotX * croppedWidth - rectWidth / 2;
const rectTop = cropTop * intrinsicHeight + hotY * croppedHeight - rectHeight / 2;
const clampedLeft = Math.max(cropLeft * intrinsicWidth, Math.min(rectLeft, cropLeft * intrinsicWidth + croppedWidth - rectWidth));
const clampedTop = Math.max(cropTop * intrinsicHeight, Math.min(rectTop, cropTop * intrinsicHeight + croppedHeight - rectHeight));
return `${Math.round(clampedLeft)},${Math.round(clampedTop)},${Math.round(rectWidth)},${Math.round(rectHeight)}`;
}In the RSC, I call this before passing the URL to Next.js Image:
// app/(site)/case-studies/[slug]/page.tsx
import Image from 'next/image';
import imageUrlBuilder from '@sanity/image-url';
import { calculateFocalRect } from '@/lib/sanity/image-crop';
import { sanityFetch } from '@/lib/sanity/client';
import { CASE_STUDY_QUERY } from '@/lib/sanity/queries';
export default async function CaseStudyPage({ params }: { params: { slug: string } }) {
const study = await sanityFetch({ query: CASE_STUDY_QUERY, params });
const builder = imageUrlBuilder({ projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!, dataset: process.env.NEXT_PUBLIC_SANITY_DATASET! });
const rect = calculateFocalRect(study.hero.asset, study.hero.hotspot, study.hero.crop, 16 / 9);
const src = builder.image(study.hero.asset).rect(rect).width(1600).height(900).format('webp').url();
return (
<section className="relative h-[60vh]">
<Image
src={src}
alt={study.hero.alt}
fill
sizes="100vw"
priority
className="object-cover"
/>
</section>
);
}Results
Before: CLS 0.18, LCP 2.7s on Moto G4 (3G throttle).
After: CLS 0.02, LCP 2.2s.
The image paints in one frame. No horizontal jump. Editors still use the hotspot picker in Sanity Studio; the crop adapts but the layout doesn't shift.
Trade-offs
You lose the ability to request different crops client-side (e.g., portrait on mobile, landscape on desktop). If you need responsive art direction, calculate two rects and use <picture> with srcset. The extra bytes are worth it if CLS is a conversion blocker.
You also need the image asset's intrinsic dimensions. Sanity's image object includes metadata.dimensions, but you must project it in your GROQ query:
*[_type == "caseStudy" && slug.current == $slug][0] {
hero {
asset->{ _id, url, metadata { dimensions } },
hotspot,
crop,
alt
}
}If you skip metadata, the helper falls back to a 2000×1333 guess, which works for most editorial images but breaks on square or portrait uploads.
Why I don't use Sanity's auto-crop
Sanity's CDN supports fit=crop&crop=focalpoint, which centers the crop on the hotspot automatically. It works, but Next.js Image still calculates the final URL client-side, and the crop rectangle can shift if the editor updates the hotspot between builds. Pre-calculating the rect guarantees the same URL every deploy, which means the browser can prefetch and paint in one frame.
If you're shipping editorial sites where editors reframe images weekly, this pattern saves you from layout-shift regressions without locking down the CMS workflow.
Related posts
All posts →How I reduced Sanity webhook fanout by batching revalidation tags in a single route handler
May 09, 2026 · 5 min read
A pattern for consolidating dozens of per-document Next.js revalidations into one tagged endpoint, cutting webhook noise and deploy times by 70%.
How I replaced Sanity's default slug generator with a custom validation hook to catch duplicate slugs before publish
May 07, 2026 · 4 min read
A pattern for enforcing unique slugs across Sanity document types using a custom validation function and GROQ query, preventing duplicate URL conflicts at schema level.
How I Handle Sanity Content Versioning Without Breaking Client Pages
May 05, 2026 · 5 min read
A production pattern for rolling schema changes in Sanity without throwing runtime errors or requiring a full site rebuild.