Sanity's hotspot and crop system works well when all the pieces line up — but if your rendered image is ignoring the focal point you set in Studio, one of four things is almost certainly wrong. None of them are subtle bugs; they're all configuration mistakes that are easy to miss and easy to fix.
The four causes (and their fixes)
1. fit is still set to clip instead of crop
This is the most common cause. The @sanity/image-url builder defaults to fit('clip'), which scales the image to fit inside the requested dimensions without cropping anything. Hotspot data is only applied when the builder is told to crop — that is, when it cuts the image down to the requested dimensions, centering the cut on the focal point.
Fix: always chain .fit('crop') when you pass .width() and .height().
// src/lib/sanity-image.ts
import imageUrlBuilder from '@sanity/image-url'
import { client } from './sanity-client'
const builder = imageUrlBuilder(client)
export function urlFor(source: SanityImageSource) {
return builder.image(source)
}
// Usage — hotspot will only apply if fit is 'crop'
const url = urlFor(image)
.width(800)
.height(600)
.fit('crop') // <-- required for hotspot to do anything
.auto('format')
.url()
Without .fit('crop'), Sanity's CDN receives no crop instruction and the hotspot coordinates are silently ignored.
2. Missing options: { hotspot: true } on the schema field
If the image field in your Sanity schema is not configured with hotspot support, Studio never renders the focal point UI, and the hotspot and crop keys are never written to the document in the first place. The URL builder can't use data that isn't there.
Fix: add options: { hotspot: true } to every image field where editors need focal control.
// schemas/post.ts
export default {
name: 'post',
type: 'document',
fields: [
{
name: 'coverImage',
type: 'image',
options: {
hotspot: true, // <-- enables the focal point UI in Studio
},
},
],
}
After adding this, existing documents will still have no hotspot data — editors need to open each document and set the focal point manually, then republish.
3. Passing only asset._ref to the URL builder instead of the full image object
This one is common when developers destructure a GROQ projection too aggressively. The URL builder needs the full image object — asset, crop, and hotspot — to apply the focal point. If you pass only the reference string or only { asset }, the builder has no crop/hotspot coordinates to work with.
// ❌ Wrong — only the reference, crop and hotspot are lost
"coverImage": coverImage.asset._ref
// ✅ Correct — project the full image object
"coverImage": coverImage {
asset,
crop,
hotspot
}
In your component, pass the full object:
// src/components/CoverImage.tsx
import { urlFor } from '@/lib/sanity-image'
import type { SanityImageObject } from '@sanity/image-url/lib/types/types'
interface Props {
image: SanityImageObject // asset + crop + hotspot
}
export function CoverImage({ image }: Props) {
const src = urlFor(image)
.width(1200)
.height(630)
.fit('crop')
.auto('format')
.url()
return <img src={src} alt="" width={1200} height={630} />
}
If you use Sanity TypeGen, the inferred type for a projected image field with crop and hotspot will match SanityImageObject automatically — a good reason to keep TypeGen up to date.
4. Hotspot values outside the 0–1 range from a bad data import
Sanity stores hotspot as { x, y, width, height } where every value is a float between 0 and 1 (fraction of the image dimensions). If you migrated content from another system or ran a custom import script, it's possible the hotspot data was written with pixel values instead of fractional values — for example { x: 320, y: 240, width: 640, height: 480 } on a 1280×960 image instead of the correct { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }.
Pixel-valued coordinates don't cause a runtime error; the CDN just receives coordinates it can't map sensibly, so the crop lands in a wrong or extreme position.
How to verify: open the raw document in Sanity Studio (three-dot menu → Inspect, or sanity documents get <id> via the CLI) and check the hotspot values. If any of x, y, width, or height are greater than 1, the import was wrong.
Fix: write a migration script using @sanity/client to normalise the values.
// scripts/fix-hotspot-values.ts
import { createClient } from '@sanity/client'
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET!,
token: process.env.SANITY_WRITE_TOKEN!,
apiVersion: '2024-01-01',
useCdn: false,
})
const IMAGE_WIDTH = 1280
const IMAGE_HEIGHT = 960
const docs = await client.fetch(
`*[_type == 'post' && defined(coverImage.hotspot)]{ _id, coverImage }`
)
for (const doc of docs) {
const h = doc.coverImage.hotspot
// Only patch if values look like pixels (> 1)
if (h.x > 1 || h.y > 1) {
await client
.patch(doc._id)
.set({
'coverImage.hotspot': {
x: h.x / IMAGE_WIDTH,
y: h.y / IMAGE_HEIGHT,
width: h.width / IMAGE_WIDTH,
height: h.height / IMAGE_HEIGHT,
},
})
.commit()
console.log(`Patched ${doc._id}`)
}
}
Run this against your staging dataset first. Verify a few documents in Studio before running on production.
How to confirm hotspot is working
The fastest sanity check: open browser DevTools, copy the generated image URL, and append &debug=true — Sanity's CDN won't recognise that param, but you can manually inspect the URL for the crop query string parameter. A working hotspot URL will contain something like crop=0.1,0.05,0.1,0.05 (left, top, right, bottom fractions). If crop is absent, the builder didn't receive crop/hotspot data or fit wasn't set to crop.
You can also pass the image object through the builder and log .options to confirm the builder has received all three keys before generating the URL:
const img = urlFor(image).width(800).height(600).fit('crop')
console.log((img as any).options) // { source: { asset, crop, hotspot }, width, height, fit }
All four of the causes above produce identical symptoms — a hotspot that looks set in Studio but has no effect on the rendered image. Work through them in order: fit setting first (takes ten seconds to check), schema options second, GROQ projection third, and data integrity last.
Top comments (0)