Type something to search...
Hotspot and Crop: Smart Image Cropping in Sanity

Hotspot and Crop: Smart Image Cropping in Sanity

Hotspot and crop are two editor tools on Sanity image fields that control how an image is framed when your front end shows it at different sizes. The crop removes parts of the image that should never be shown, such as a messy edge or a watermark. The hotspot marks the area that must stay visible, such as a face or product, when the image is cropped further to fit a layout. You enable both with options: { hotspot: true } on an image field, and @sanity/image-url applies them automatically when you request an image with both a width and a height.

This matters because one image often appears in several shapes: a wide hero, a square card and a tall mobile banner. Without hotspot, a centred crop might cut off someone's head in the square version. With it, editors decide once what matters, and every layout respects that choice. This guide explains how the two tools differ, how to enable and configure them, what the stored data looks like, how the URL builder uses it, how to apply hotspot with CSS when you can't use the builder, and the common reasons it seems not to work.

Crop vs Hotspot

The two tools solve different problems and are stored separately:

ToolWhat the editor doesEffect on the front end
CropDrags the edges of a frame inwardsThose areas are removed from every rendered version
HotspotDraws an ellipse around the most important areaFurther cropping is centred on this area and tries to keep it in view

Think of crop as "never show this" and hotspot as "always try to show this". A product photo might be cropped to remove studio equipment at the edges, with the hotspot on the product itself.

Neither tool changes the uploaded file. The original asset stays untouched, and the crop and hotspot values are stored on the image field in your document. That means the same asset can be cropped differently in two documents.

Enable Hotspot and Crop

Both tools are switched on by a single option. It's false by default:

// schemaTypes/post.ts
import { defineField, defineType } from "sanity";

export const post = defineType({
  name: "post",
  title: "Post",
  type: "document",
  fields: [
    defineField({ name: "title", type: "string" }),
    defineField({
      name: "mainImage",
      title: "Main image",
      type: "image",
      options: {
        hotspot: true,
      },
      fields: [
        defineField({ name: "alt", title: "Alternative text", type: "string" }),
      ],
    }),
  ],
});

Once enabled, editors see a crop icon on the image field in Sanity Studio. Clicking it opens the editing dialog, with the crop frame on the outside and the hotspot ellipse in the middle. Changes save immediately like any other field.

If you haven't set up image fields yet, start with how to handle images in Sanity.

Show Previews for Your Layouts

By default the dialog shows a few generic preview shapes. You can replace them with the aspect ratios your site actually uses, so editors see exactly how each layout will crop:

defineField({
  name: "mainImage",
  type: "image",
  options: {
    hotspot: {
      previews: [
        { title: "Hero (16:9)", aspectRatio: 16 / 9 },
        { title: "Card (1:1)", aspectRatio: 1 },
        { title: "Mobile banner (4:5)", aspectRatio: 4 / 5 },
        { title: "Social (1.91:1)", aspectRatio: 1.91 },
      ],
    },
  },
});

This is one of the most useful and least known options. Editors stop guessing, and you get fewer messages saying "the image looks wrong on mobile".

How the Data Is Stored

After an editor adjusts the image, the field contains crop and hotspot objects alongside the asset reference:

{
  "mainImage": {
    "_type": "image",
    "asset": {
      "_type": "reference",
      "_ref": "image-7f3c9a1e5b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a-2400x1600-jpg"
    },
    "crop": {
      "_type": "sanity.imageCrop",
      "top": 0.05,
      "bottom": 0.12,
      "left": 0,
      "right": 0.08
    },
    "hotspot": {
      "_type": "sanity.imageHotspot",
      "x": 0.62,
      "y": 0.38,
      "width": 0.25,
      "height": 0.4
    }
  }
}

All values are fractions of the original image's dimensions, between 0 and 1:

  • Crop: Each value is how much is removed from that edge. "bottom": 0.12 removes the bottom 12% of the image.
  • Hotspot: x and y are the centre of the ellipse, measured from the top left. width and height are the size of the ellipse.

Using fractions rather than pixels means the values stay valid whatever size the image is rendered at. If the editor hasn't touched the tools, the fields may be missing entirely, or contain default values: a crop of all zeros and a hotspot centred at 0.5 with a width and height of 1.

Query the Fields You Need

The URL builder needs crop and hotspot to do its job, so include them in your GROQ projection:

*[_type == "post" && slug.current == $slug][0]{
  title,
  mainImage{
    asset,
    crop,
    hotspot,
    alt
  }
}

A common mistake is projecting only "imageUrl": mainImage.asset->url. That gives you a URL to the original file with no framing information at all. If you're unsure how projections and references fit together, see using projections in GROQ queries.

How the URL Builder Applies Them

Pass the whole image field to @sanity/image-url and set both dimensions:

import { urlFor } from "@/sanity/lib/image";

const hero = urlFor(post.mainImage)
  .width(1600)
  .height(900)
  .auto("format")
  .url();
const card = urlFor(post.mainImage).width(600).height(600).auto("format").url();
const banner = urlFor(post.mainImage)
  .width(800)
  .height(1000)
  .auto("format")
  .url();

For each request, the builder:

  1. Applies the editor's crop, giving it a smaller usable area of the image.
  2. Works out the largest rectangle with your requested aspect ratio that fits inside that area.
  3. Positions that rectangle so the hotspot is as close to its centre as possible, without going outside the cropped area.
  4. Writes the result as a rect=left,top,width,height parameter in pixels.

The resulting URL looks like this:

https://cdn.sanity.io/images/abc123/production/7f3c9a1e...-2400x1600.jpg?rect=702,80,1325,1325&w=600&h=600&auto=format

Two rules decide whether hotspot is used:

  • You must pass the image field: Passing post.mainImage.asset or an asset ID string loses the crop and hotspot, because they live on the field.
  • You must set both width and height: With only a width, the image keeps its natural aspect ratio, so there's nothing to crop and the hotspot has no effect. The crop is still applied.

Every builder method is explained in using Sanity's image URL builder.

Overriding the Editor's Choices

Sometimes you need a specific framing regardless of what the editor set:

  • ignoreImageParams(): Ignores both crop and hotspot. Useful for a lightbox that should always show the full original.
  • focalPoint(x, y): Sets a focal point from 0 to 1 that overrides the hotspot.
  • rect(left, top, width, height): Crops to an exact pixel rectangle, overriding both.
// Always show the full, uncropped image in a lightbox
urlFor(image).ignoreImageParams().width(2400).fit("max").url();

Use Hotspot With CSS object-position

Sometimes the image's final shape is decided by CSS rather than a fixed width and height, for example a hero with height: 60vh that changes shape at every viewport width. Requesting a new crop for every possible ratio isn't practical. Instead, request a reasonably sized image without forcing a ratio, and use the hotspot to set object-position:

// src/components/cover-image.tsx
import Image from "next/image";
import { urlFor } from "@/sanity/lib/image";

type Hotspot = { x: number; y: number };

type CoverImageProps = {
  image: {
    asset: { _ref: string };
    hotspot?: Hotspot;
    alt?: string;
  };
};

export function CoverImage({ image }: CoverImageProps) {
  const position = image.hotspot
    ? `${image.hotspot.x * 100}% ${image.hotspot.y * 100}%`
    : "50% 50%";

  return (
    <div className="relative h-[60vh] w-full overflow-hidden">
      <Image
        src={urlFor(image).width(2000).fit("max").auto("format").url()}
        alt={image.alt ?? ""}
        fill
        sizes="100vw"
        className="object-cover"
        style={{ objectPosition: position }}
        preload
      />
    </div>
  );
}

object-fit: cover fills the box, and object-position shifts the image so the hotspot centre stays in view as the box changes shape. It's an approximation, because CSS uses the hotspot's centre point rather than its full size, but it works well for most hero layouts.

Note that fit('max') without a height doesn't use the hotspot, but the editor's crop is still applied to the URL. That's usually what you want: unwanted edges are removed by the CDN, and the framing is done by CSS.

Art Direction With the picture Element

For layouts with distinct shapes at different breakpoints, combine the builder with <picture>. Each source gets its own ratio, and the hotspot keeps the subject in frame in all of them:

import type { SanityImageObject } from "@sanity/image-url";
import { urlFor } from "@/sanity/lib/image";

export function ArtDirectedHero({
  image,
}: {
  image: SanityImageObject & { alt?: string };
}) {
  const mobile = urlFor(image).width(800).height(1000).auto("format").url();
  const tablet = urlFor(image).width(1200).height(900).auto("format").url();
  const desktop = urlFor(image).width(1920).height(800).auto("format").url();

  return (
    <picture>
      <source media="(min-width: 1024px)" srcSet={desktop} />
      <source media="(min-width: 640px)" srcSet={tablet} />
      <img src={mobile} alt={image.alt ?? ""} className="h-auto w-full" />
    </picture>
  );
}

This is where the previews option pays off. If the previews in Studio match these three ratios, editors can check every version before publishing.

Detect Unset Hotspots

@sanity/asset-utils includes helpers that tell you whether the editor actually changed the defaults:

import { isDefaultHotspot } from "@sanity/asset-utils";

if (!image.hotspot || isDefaultHotspot(image.hotspot)) {
  // Fall back to entropy-based cropping
}

When no hotspot is set, Sanity's image pipeline can try to find the most interesting part of the image itself with crop=entropy:

urlFor(image).width(600).height(600).fit("crop").crop("entropy").url();

You can also find documents where editors haven't set a hotspot, which is handy for a content audit:

*[_type == "post" && defined(mainImage.asset) && !defined(mainImage.hotspot)]{
  _id,
  title
}

Common Problems

  • Hotspot seems to be ignored: Check that you're passing the full image field, that hotspot is in your query, and that you set both width() and height().
  • Crop tool is missing in Studio: options.hotspot isn't set to true on that field, or you're using a different image type than you think. Check the field's type.
  • Images look wrong after changing hotspot to true: Existing images don't have hotspot data yet, so they fall back to centre cropping until an editor sets it.
  • The same image is cropped differently in two places: That's expected. Crop and hotspot belong to each image field, not the asset, so each document can frame the image its own way.
  • Hotspot works for images but not inside rich text: Image blocks in Portable Text need options: { hotspot: true } on their own type definition, and your Portable Text query must return the block's crop and hotspot.

Tips for Editors

Hotspot only helps if editors use it. A short note in the field description goes a long way:

defineField({
  name: "mainImage",
  type: "image",
  description:
    "Click the crop icon to set the focus area. Keep faces and products inside the circle.",
  options: { hotspot: true },
});

Encourage editors to draw the hotspot tightly around the subject rather than leaving it at the default size. A smaller hotspot gives the builder more freedom to choose a good crop. If you're building out a full image workflow, optimising Sanity images for performance is a good next step.


FAQ: Hotspot and Crop in Sanity

No. Crop and hotspot are stored as fractional values on the image field in your document. The original asset is unchanged, and the image pipeline applies the crop each time a URL with transformation parameters is requested.

The builder needs the full image object, including crop and hotspot, and both a width and a height. If you pass only the asset, omit hotspot from your query or set just one dimension, the hotspot can't be applied.

No. The single hotspot option enables both tools together. If you don't want hotspot to influence a particular rendering, call ignoreImageParams() or set your own focalPoint() for that URL.

They're fractions of the image's width and height. x and y give the centre of the hotspot ellipse from the top left corner, and width and height give its size. A value of 0.5 for x means the horizontal centre of the image.

Yes. Read hotspot.x and hotspot.y from your query and set CSS object-position to those values as percentages on an image with object-fit: cover. It's an approximation, but it works well for fluid layouts.

No. Images uploaded before you enabled hotspot have no hotspot values until an editor opens them and sets one. Until then, they're cropped from the centre, or by entropy if you request crop=entropy.


Conclusion

Hotspot and crop give editors control over framing without uploading multiple versions of an image. Crop removes what should never be shown, hotspot marks what must stay visible, and both are stored as fractional values on the image field. Enable them with options.hotspot, add previews that match your layouts, and query crop and hotspot alongside the asset.

On the front end, pass full image fields to @sanity/image-url with both dimensions set, and the builder turns those values into a precise rect for every shape you need. For fluid layouts, use the hotspot as object-position. With those pieces in place, a single upload looks deliberate everywhere it appears.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper