javascriptcssimagenext.jssanity

How to use Sanity image hotspot when dynamically sized


tl;dr I want to keep the Sanity hotspot on screen in a 100vw and 100dvh section, no matter how the user sizes it.

Goal

On my client's website, I have an image slider for the background of the hero section. To maximize on displaying the images well, the section fills the viewport width and viewport height. I want to make sure the hotspot is always visible in this space, no matter what size the section is—as it's very dynamic.

Issue

No matter what I try, the hotspot never seems to do anything for me. Some things I've tried do nothing, while others move the image in too far, revealing whitespace behind the image. Even my closest attempts don't seem to contain the hotspot, regardless.

Code

Image Builder

This is just the usual image builder helper for Sanity

// /sanity.ts

import createImageUrlBuilder from '@sanity/image-url'

/**
 * ### imageUrlFor
 * - Gets the Sanity URL for images
 * - Has helper functions for customizing the output file
 *
 * @param source SanityImage
 * @returns url
 * @example imageUrlFor(image)?.url()
 * @example importUrlFor(image)?.format('webp').url()
 */
export const imageUrlFor = (source: SanityImageReference | AltImage) => {
  if (!config.dataset || !config.projectId)
    throw new Error('SANITY_DATASET or SANITY_PROJECT_ID is not set correctcly')

  // @ts-expect-error dataset and projectId have been verified
  return createImageUrlBuilder(config).image(source)
}

Helper function for resizing image

(not important; just used in the sanity.ts below)

// /utils/functions.ts

/**
 * ### Compress Width and Height
 * - Resize the width and height it's the ratio, to the max width/height in pixels
 * @param {number} width
 * @param {number} height
 * @param {number} max width/height (default: 25)
 * @returns {{ width: number, height: number }} { width: number, height: number }
 */
export function compressWidthAndHeight(
  width: number,
  height: number,
  max: number = 25,
): { width: number; height: number } {
  if (width === 0 || height === 0)
    throw new Error(
      'Cannot divide by zero. Please provide non-zero values for the width and height.',
    )

  if (width > height)
    return {
      width: max,
      height: Math.ceil((max * height) / width),
    }
  return {
    width: Math.ceil((max * width) / height),
    height: max,
  }
}

My function to prepare images for the Next.js Image component

This is where I want to be able to add the hotspot somehow, whether that's somehow in the imageUrlFor

// /utils/sanity.ts

import { SanityImageReference } from '@/typings/sanity'
import { getImageDimensions } from '@sanity/asset-utils'
import { ImageProps } from 'next/image'
import { compressWidthAndHeight } from './functions'
import { imageUrlFor } from '@/sanity'

/**
 * ### Prepare Image
 * @param {SanityImageReference & { alt: string }} image
 * @param {Partial<{ aspect: number, max: number, sizes: string, quality: number }>} options
 * @options aspect width/height
 * @options dpr pixel density (default: 2)
 * @options max width or height (default: 2560)
 * @options sizes
 * @options quality 1-100 (default: 75)
 * @returns {ImageProps}
 */
export function prepareImage(
  image: SanityImageReference & { alt: string },
  options?: Partial<{
    aspect: number
    dpr: number
    max: number
    sizes: string
    quality: number
  }>,
): ImageProps {
  const { alt } = image

  const aspect = options?.aspect,
    dpr = options?.dpr || 2,
    max = options?.max || 2560,
    sizes = options?.sizes,
    quality = options?.quality || 75

  if (aspect && aspect <= 0) throw new Error('aspect cannot be less than 1.')
  if (dpr <= 0) throw new Error('dpr cannot be less than 1.')
  if (dpr >= 4) throw new Error('dpr cannot be greater than 3.')
  if (max <= 0) throw new Error('max cannot be less than 1.')
  if (quality <= 0) throw new Error('quality cannot be less than 1.')
  if (quality >= 101) throw new Error('quality cannot be greater than 100.')

  let { width, height } = getImageDimensions(image)

  if (aspect) {
    const imageIsLandscape = width > height,
      aspectIsLandscape = aspect > 1,
      aspectIsSquare = aspect === 1

    if (aspectIsSquare) {
      if (imageIsLandscape) width = height
      if (!imageIsLandscape) height = width
    }

    if (aspectIsLandscape) {
      height = Math.round(width / aspect)
      width = Math.round(height * aspect)
    } else if (!aspectIsSquare) {
      height = Math.round(width / aspect)
      width = Math.round(height * aspect)
    }
  }

  // For the full image
  const { width: sizedWidth, height: sizedHeight } = compressWidthAndHeight(
    width,
    height,
    max,
  )

  // For the blurDataUrl
  const { width: compressedWidth, height: compressedHeight } =
    compressWidthAndHeight(width, height)

  const baseSrcUrl = imageUrlFor(image).format('webp').fit('crop')

  const src = baseSrcUrl
    .size(sizedWidth, sizedHeight)
    .quality(quality)
    .dpr(dpr)
    .url()

  const blurDataURL = baseSrcUrl
    .quality(15)
    .size(compressedWidth, compressedHeight)
    .dpr(1)
    .blur(200)
    .auto('format')
    .url()

  return {
    id: `${image.asset._ref}`,
    src,
    alt,
    width: sizedWidth,
    height: sizedHeight,
    sizes,
    quality: quality,
    placeholder: 'blur',
    blurDataURL,
  }
}

Example

const img = document.querySelector('#example-image')

const hotspot = {
  x: 0.804029,
  y: 0.239403,
  width: 0.154323,
  height: 0.154323
}

// This lines up the hotspot with the top left corner of the container—sort of…
// This is not the intended result, but it was my best attempt at least locating the hotspot in some way.
img.style.top = `${hotspot.y * 100}`
img.style.left = `${hotspot.x * 100}`
img.style.transform = `translateX(-${hotspot.x * 100}%) translateY(-${hotspot.y * 100}%) scaleX(${hotspot.width * 100 + 100}%) scaleY(${hotspot.height * 100 + 100}%)`
* {
  margin: 0;
  padding: 0;
  position: relative;
  min-width: 0;
}

html {
  color-scheme: light dark;
}

body {
  min-height: 100svh;
  font-family: ui-sans-serif;
}

img {
  font-style: italic;
  background-repeat: no-repeat;
  background-size: cover;
  shape-margin: 0.75rem;
  width: 100%;
  font-family: ui-serif;
}

h1 {
  font-weight: unset;
  font-size: unset;
}

.absolute {
  position: absolute;
}

.inset-0 {
  inset: 0;
}

.\-z-10 {
  z-index: -10;
}

.flex {
  display: flex;
}

.size-screen {
  width: 100vw;
  height: 100dvh;
}

.h-full {
  height: 100%
}

.items-center {
  align-items: center;
}

.justify-center {
  justify-content: center;
}

.mb-2 {
  margin-bottom: 0.5rem;
}

.ply-4 {
  padding-block: 1rem;
}

.plx-8 {
  padding-inline: 2rem;
}

.rounded-tl-10xl {
  border-top-left-radius: 4.5rem;
}

.rounded-tr-lg {
  border-top-right-radius: 0.5rem;
}

.rounded-bl-lg {
  border-bottom-left-radius: 0.5rem;
}

.rounded-br-10xl {
  border-bottom-right-radius: 4.5rem;
}

.bg-black\/60 {
  background-color: rgb(0 0 0 / 0.6)
}

.text-white {
  color: white;
}

.text-lg {
  font-size: 1.125rem;
}

.font-extralight {
  font-weight: 200;
}

.font-black {
  font-weight: 900;
}

.text-center {
  text-align: center;
}

.shadow-lg {
  box-shadow: 0 10px 20px 0 rgb(0 0 0 / 0.25);
}

.ring-2 {
  --ring-width: 2px;
}

.ring-inset {
  box-shadow: inset 0 0 0 var(--ring-width) var(--ring-color);
}

.ring-blue\/25 {
  --ring-color: rgb(0 0 255 / 0.25);
}

.object-cover {
  object-fit: cover;
}

.bg-blur-img {
  background-image: url(https://images.unsplash.com/photo-1711907392527-4a895b190b61?ixlib=rb-4.0.3&q=1&fm=webp&crop=entropy&cs=srgb&width=20&height=20);
}

.text-shadow {
  text-shadow: 0 2.5px 5px rgb(0 0 0 / 0.25);
}

.backdrop-brightness-150 {
  --backdrop-brightness: 1.5;
  -webkit-backdrop-filter: blur(var(--backdrop-blur)) brightness(var(--backdrop-brightness));
  backdrop-filter: blur(var(--backdrop-blur)) brightness(var(--backdrop-brightness));
}

.backdrop-blur-lg {
  --backdrop-blur: 16px;
  -webkit-backdrop-filter: blur(var(--backdrop-blur)) brightness(var(--backdrop-brightness));
  backdrop-filter: blur(var(--backdrop-blur)) brightness(var(--backdrop-brightness));
}
<section class='flex size-screen items-center justify-center ring-2 ring-inset ring-blue/25'>
  <div id='text-container' class='ply-4 plx-8 rounded-tl-10xl rounded-tr-lg rounded-bl-lg rounded-br-10xl bg-black/60 text-white text-center text-shadow shadow-lg backdrop-blur-lg backdrop-brightness-150'>
    <h1 class='text-lg font-black mb-2'>In this example, the sun is the hotspot.</h1>
    
    <p class='font-extralight'>Check JavaScript for explanation.</p>
  </div>

  <img
    id='example-image'
    src='https://images.unsplash.com/photo-1711907392527-4a895b190b61?ixlib=rb-4.0.3&q=75&fm=webp&crop=entropy&cs=srgb&width=2560&height=1707.046875'
    alt='Sunset, Canary Islands, Spain'
    width='2560'
    height='1707.046875'
    sizes='100vw'
    class='absolute inset-0 -z-10 h-full object-cover bg-blur-img'
    loading='eager'
  />
</section>


Solution

  • I finally have a great solution.

    I'll be the first to admit that it may not work for everyone or in every use case, but it works for what I intended.

    Since it's been a while since posting the question, naturally a good bit has changed in my implementation of Sanity, but you shouldn't have any issues adapting, to your own project with minor changes.

    Changes in my projects

    I'd like to start by addressing the changes I've made since posting the question. Please keep in mind all changes listed here were created with Next.js 15 and—more specifically—the next/image component in mind. You may need to make modifications if this does not apply to you.

    Fetching images

    I no longer use the imageUrlFor, compressWidthAndHeight, or prepareImage functions to generate src attribute and other image props. Instead I take advantage of the GROQ query step by pulling in the information I need and creating the src at this level. I created a helper function for querying images with GROQ, since there are many different scenarios that require different functions on the src.

    Necessary types

    If you're using TypeScript like I do, here's the definitions you'll need:

    export type SanityCrop = {
        top: number
        left: number
        bottom: number
        right: number
    }
    
    export type SanityHotspot = {
        x: number
        y: number
        width: number
        height: number
    }
    
    export type SanityImage = {
        _id: string
        alt?: string
        aspectRatio?: number
        blurDataURL: string
        crop?: SanityCrop
        height?: number
        hotspot?: SanityHotspot
        filename?: string
        src: string
        width?: number
    }
    

    GROQ Image function

    All descriptions in the GroqImageSourceOptions type are copied from Sanity – Image transformations – Image URLs. You're welcome to use this in your own projects if you want.

    type GroqImageSourceOptions = Partial<{
        /** Automatically returns an image in the most optimized format supported by the browser as determined by its Accept header. To achieve the same result in a non-browser context, use the `fm` parameter instead to specify the desired format. */
        auto: 'format'
        /** Hexadecimal code (RGB, ARGB, RRGGBB, AARRGGBB) */
        bg: string
        /** `0`-`2000` */
        blur: number
        /** Use with `fit: 'crop'` to specify how cropping is performed.
         *
         * `focalpoint` will crop around the focal point specified using the `fp` parameter.
         *
         * `entropy` attempts to preserve the "most important" part of the image by selecting the crop that preserves the most complex part of the image.
         * */
        crop:
            | 'top'
            | 'bottom'
            | 'left'
            | 'right'
            | 'top,left'
            | 'top,right'
            | 'bottom,left'
            | 'bottom,right'
            | 'center'
            | 'focalpoint'
            | 'entropy'
        /** Configures the headers so that opening this link causes the browser to download the image rather than showing it. The browser will suggest to use the file name provided here. */
        dl: string
        /** Specifies device pixel ratio scaling factor. From `1` to `3`. */
        dpr: 1 | 2 | 3
        /** Affects how the image is handled when you specify target dimensions.
         *
         * `clip` resizes to fit within the bounds you specified without cropping or distorting the image.
         *
         * `crop` crops the image to fill the size you specified when you specify both `w` and `h`.
         *
         * `fill` operates the same as `clip`, but any free area not covered by your image is filled with the color specified in the `bg` parameter.
         *
         * `fillmax` places the image within the box you specify, never scaling the image up. If there is excess room in the image, it is filled with the color specified in the `bg` parameter.
         *
         * `max` fits the image within the box you specify, but never scaling the image up.
         *
         * `min` resizes and crops the image to match the aspect ratio of the requested width and height. Will not exceed the original width and height of the image.
         *
         * `scale` scales the image to fit the constraining dimensions exactly. The resulting image will fill the dimensions, and will not maintain the aspect ratio of the input image.
         */
        fit: 'clip' | 'crop' | 'fill' | 'fillmax' | 'max' | 'min' | 'scale'
        /** Flip image horizontally, vertically or both. */
        flip: 'h' | 'v' | 'hv'
        /** Convert image to jpg, pjpg, png, or webp. */
        fm: 'jpg' | 'pjpg' | 'png' | 'webp'
        /** Specify a center point to focus on when cropping the image. Values from 0.0 to 1.0 in fractions of the image dimensions. */
        fp: {
            x: number
            y: number
        }
        /** The frame of an animated image. The only valid value is 1, which is the first frame. */
        frame: 1
        /** Height of the image in pixels. Scales the image to be that tall. */
        h: number
        /** Invert the colors of the image. */
        invert: boolean
        /** Maximum height. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying `fit: 'crop'`. */
        maxH: number
        /** Maximum width in the context of image cropping. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying `fit: 'crop'`. */
        maxW: number
        /** Minimum height. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying `fit: 'crop'`. */
        minH: number
        /** Minimum width. Specifies size limits giving the backend some freedom in picking a size according to the source image aspect ratio. This parameter only works when also specifying `fit: 'crop'`. */
        minW: number
        /** Rotate the image in 90 degree increments. */
        or: 0 | 90 | 180 | 270
        /** The number of pixels to pad the image. Applies to both width and height. */
        pad: number
        /** Quality `0`-`100`. Specify the compression quality (where applicable). Defaults are `75` for JPG and WebP. */
        q: number
        /** Crop the image according to the provided coordinate values. */
        rect: {
            left: number
            top: number
            width: number
            height: number
        }
        /** Currently the asset pipeline only supports `sat: -100`, which renders the image with grayscale colors. Support for more levels of saturation is planned for later. */
        sat: -100
        /** Sharpen `0`-`100` */
        sharp: number
        /** Width of the image in pixels. Scales the image to be that wide. */
        w: number
    }>
    
    function applySourceOptions(src: string, options: GroqImageSourceOptions) {
        const convertedOptions = Object.entries(options)
            .map(
                ([key, value]) =>
                    `${breakCamelCase(key).join('-').toLowerCase()}=${typeof value === 'string' || typeof value === 'boolean' ? value : typeof value === 'number' ? Math.round(value) : Object.values(value).join(',')}`,
            )
            .join('&')
    
        return src + ` + "?${convertedOptions}"`
    }
    
    type GroqImageProps = Partial<{
        alt: boolean
        /** Returns the aspect ratio of the image */
        aspectRatio: boolean
        /** Precedes asset->url */
        assetPath: string
        blurDataURL: boolean
        /** Returns the coordinates of the crop */
        crop: boolean
        /** Returns the height of the image */
        height: boolean
        /** Returns the hotspot of the image */
        hotspot: boolean
        filename: boolean
        otherProps: string[]
        src: GroqImageSourceOptions
        /** Returns the width of the image */
        width: boolean
    }>
    
    /**
     * # GROQ Image
     *
     * **Generates the necessary information for extracting the image asset, with built-in and typed options, making it easier to use GROQ's API as it relates to image fetching.**
     *
     * - Include `alt` and `blurDataURL` whenever possible.
     *
     * - It's best to always specify the `src` options as well.
     *
     * - Include either `srcset` or `sources` for best results.
     *
     * - `srcset` generates URLs for the `srcset` attribute of an `<img>` element.
     *
     * - `sources` generates URLs for `<source>` elements, used in the `<picture>` element.
     */
    export function groqImage(props?: GroqImageProps) {
        const prefix = props?.tabStart ? `\n${'  '.repeat(props.tabStart)}` : '\n  ',
            assetPath = props?.assetPath ? `${props.assetPath}.` : ''
    
        let constructor = `{`
    
        if (props?.otherProps) constructor = constructor + prefix + props.otherProps.join(`,${prefix}`) + `,`
    
        if (props?.alt) constructor = constructor + prefix + `"alt": ${assetPath}asset->altText,`
    
        if (props?.crop) {
            let crop = 'crop,'
    
            if (props.assetPath) crop = `"crop": ${assetPath}crop,`
    
            constructor = constructor + prefix + crop
        }
    
        if (props?.hotspot) {
            let hotspot = 'hotspot,'
    
            if (props.assetPath) hotspot = `"hotspot": ${assetPath}hotspot,`
    
            constructor = constructor + prefix + hotspot
        }
    
        if (props?.width) constructor = constructor + prefix + `"width": ${assetPath}asset->metadata.dimensions.width,`
    
        if (props?.height) constructor = constructor + prefix + `"height": ${assetPath}asset->metadata.dimensions.height,`
    
        if (props?.aspectRatio)
            constructor = constructor + prefix + `"aspectRatio": ${assetPath}asset->metadata.dimensions.aspectRatio,`
    
        if (props?.blurDataURL) constructor = constructor + prefix + `"blurDataURL": ${assetPath}asset->metadata.lqip,`
    
        if (props?.filename) constructor = constructor + prefix + `"filename": ${assetPath}asset->originalFilename,`
    
        constructor = constructor + prefix + `"src": ${assetPath}asset->url`
    
        if (props?.src && Object.entries(props.src).length >= 1) constructor = applySourceOptions(constructor, props.src)
    
        return constructor
    }
    

    Generating the image props

    Although most props are now prepared with groqImage—like the alt and blurDataURL for next/image—the crop, hotspot, width, and height still aren't utilized. To utilize I created a couple helper functions that are implemented into the main getImagePropsFromSanityForSizing function.

    applyCropToImageSource calculates the rect search parameter of the Sanity image URL to apply the crop based on the image's dimensions.

    applyHotspotToImageSource uses the x and y values of the hotspot for the fx and fy focal points defined in the search parameters. It also makes sure the crop search parameter is set to focalpoint.

    getImagePropsForSizingFromSanity applies both previously mentioned functions to the src and calculates the maximum width and height attributes based on the actual dimensions of the image in Sanity, compared to the developer-defined max dimensions. If no max width and height are provided, the width and height props remain undefined. This is intentional, so that the fill prop can be properly utilized.

    export function applyCropToImageSource(src: string, crop?: SanityCrop, width?: number, height?: number) {
        if (!crop || !width || !height) return src
    
        const { top, left, bottom, right } = crop
    
        const croppedWidth = width - right * width,
            croppedHeight = height - bottom * height
    
        const rect = `&rect=${Math.round(left)},${Math.round(top)},${Math.round(croppedWidth)},${Math.round(croppedHeight)}`
    
        return src + rect
    }
    
    export function applyHotspotToImageSource(src: string, hotspotCoords?: Pick<SanityHotspot, 'x' | 'y'>) {
        if (!hotspotCoords) return src
    
        const { x, y } = hotspotCoords
    
        const fx = `&fx=${x}`,
            fy = `&fy=${y}`
    
        if (src.includes('&crop=') && !src.includes('&crop=focalpoint')) {
            src = src.replace(
                /&crop=(top|bottom|left|right|top,left|top,right|bottom,left|bottom,right|center|entropy)/,
                '&crop=focalpoint',
            )
        } else {
            src = src + `&crop=focalpoint`
        }
    
        if (!Number.isNaN(x) && x <= 1 && x >= 0) src = src + fx
        if (!Number.isNaN(y) && y <= 1 && y >= 0) src = src + fy
    
        return src
    }
    
    /**
     * # Get Image Props for Sizing from Sanity
     *
     * - Returns src, height, and width for `next/image` component
     * - Both sanity and max heights and widths must be included to include height and width props
     * - The src will have focalpoints and cropping applied to it, according to the provided crop, hotspot, and dimensions.
     */
    export function getImagePropsForSizingFromSanity(
        src: string,
        {
            crop,
            height,
            hotspot,
            width,
        }: Partial<{
            crop: SanityCrop
            height: Partial<{ sanity: number; max: number }>
            hotspot: SanityHotspot
            width: Partial<{ sanity: number; max: number }>
        }>,
    ): Pick<ImageProps, 'src' | 'height' | 'width'> {
        return {
            src: applyHotspotToImageSource(applyCropToImageSource(src, crop, width?.sanity, height?.sanity), hotspot),
            height: height?.max ? Math.min(height.sanity || Infinity, height.max) : undefined,
            width: width?.max ? Math.min(width.sanity || Infinity, width.max) : undefined,
        }
    }
    

    And lastly, it should be noted that the next.config.ts is modified to implement a custom loader to take advantage of Sanity's built image pipeline.

    // next.config.ts
    
    import type { NextConfig } from 'next'
    
    const nextConfig: NextConfig = {
        images: {
            formats: ['image/webp'],
            loader: 'custom',
            loaderFile: './utils/sanity-image-loader.ts',
            remotePatterns: [
                {
                    protocol: 'https',
                    hostname: 'cdn.sanity.io',
                    pathname: '/images/[project_id]/[dataset]/**',
                    port: '',
                },
            ],
        },
    }
    
    export default nextConfig
    
    // sanity-image-loader.ts
    
    // * Image
    import { ImageLoaderProps } from 'next/image'
    
    export default function imageLoader({ src, width, quality }: ImageLoaderProps) {
        if (src.includes('cdn.sanity.io')) {
            const url = new URL(src)
    
            const maxW = Number(url.searchParams.get('max-w'))
    
            url.searchParams.set('w', `${!maxW || width < maxW ? width : maxW}`)
            if (quality) url.searchParams.set('q', `${quality}`)
    
            return url.toString()
        }
    
        return src
    }
    

    Utilizing the hotspot dynamically

    Now that we got the boring stuff out of the way, let's talk about how implementation of the hotspot actually works.

    The hotspot object is defined like this (in TypeScript):

    type SanityHotspot = {
        x: number
        y: number
        width: number
        height: number
    }
    

    All of these values are numbers 0-1, which means multiplying each value by 100 and adding a % at the end, will generally be how we will implement the values.

    x and y are the center of the hotspot. width and height are fractions of the dimensions of the image.

    Now there are certainly different ways of using these values to get the results you're looking for (e.g. top, left, and/or translate), but I wanted to use the object-position CSS property, since it doesn't require wrapping the <img> element in a <div> and it works well with object-fit: cover;.

    The most important thing to dynamically position the image to keep the hotspot in view is handling resize events. Since I'm using Next.js, I created a React hook to handle this.

    useResize

    I made this hook to return the dimensions of either the specified element, or the window, so it can be used for anything. In our use case, the dimensions of the image is all we care about. It also manages multiple functions with one resize event to help with performance.

    'use client'
    
    import { RefObject, useEffect, useState, useCallback, useLayoutEffect, useRef } from 'react'
    
    // Create a global store for resize callbacks
    type ResizeCallback = () => void
    const resizeCallbacks = new Set<ResizeCallback>()
    let resizeListenerAdded = false
    
    // Function to set up the global resize listener
    function setupResizeListener() {
        if (typeof window === 'undefined' || resizeListenerAdded) return
    
        const handleResize = () => {
            // console.log(`Resize event fired, notifying ${resizeCallbacks.size} listeners`)
            resizeCallbacks.forEach(callback => callback())
        }
    
        window.addEventListener('resize', handleResize)
        resizeListenerAdded = true
    
        // We don't need to clean this up as it should persist for the lifetime of the app
        // But if you want to clean up during hot reloads in development:
        if (process.env.NODE_ENV === 'development') {
            return () => {
                window.removeEventListener('resize', handleResize)
                resizeListenerAdded = false
            }
        }
    
        return null
    }
    
    // Initialize the listener when this module is first imported
    if (typeof window !== 'undefined') {
        setupResizeListener()
    }
    
    // The hook that components will use
    export function useResize(el?: RefObject<HTMLElement | null> | HTMLElement) {
        const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
    
        // Create a stable callback reference
        const updateDimensions = useCallback(() => {
            const trackedElement = el ? ('current' in el ? el.current : el) : null
    
            setDimensions({
                width: trackedElement ? trackedElement.clientWidth : window.innerWidth,
                height: trackedElement ? trackedElement.clientHeight : window.innerHeight,
            })
        }, [el])
    
        // Keep a ref to the latest callback to avoid dependency issues
        const updateDimensionsRef = useRef(updateDimensions)
    
        useLayoutEffect(() => {
            updateDimensionsRef.current = updateDimensions
        }, [updateDimensions])
    
        useEffect(() => {
            if (typeof window === 'undefined') return
    
            // Make sure the global listener is set up
            setupResizeListener()
    
            // Create a stable function that can be added and removed from the set
            const callbackFn = () => updateDimensionsRef.current()
    
            // Register this component's callback
            resizeCallbacks.add(callbackFn)
    
            // Measure initially
            updateDimensions()
    
            // Clean up when component unmounts
            return () => {
                resizeCallbacks.delete(callbackFn)
            }
        }, [updateDimensions])
    
        return dimensions
    }
    

    Custom image component

    Now that we have our useResize hook, we can use it and apply the object-position to dynamically position the image to keep the hotspot in view. Naturally, we'll want to create a new component, so it can be used easily when we need it.

    This image component is built off of the next/image component, since we still want to take advantage of all that that component has to offer.

    'use client'
    
    // * Types
    import { SanityHotspot } from '@/typings/sanity'
    
    export type ImgProps = ImageProps & { hotspotPositioning?: { aspectRatio?: number; hotspot?: SanityHotspot } }
    
    // * React
    import { RefObject, useEffect, useRef, useState } from 'react'
    
    // * Hooks
    import { useResize } from '@/hooks/use-resize'
    
    // * Components
    import Image, { ImageProps } from 'next/image'
    
    export default function Img({ hotspotPositioning, style, ...props }: ImgProps) {
        const imageRef = useRef<HTMLImageElement>(null),
            { objectPosition } = useHotspot({ ...hotspotPositioning, imageRef })
    
        return <Image {...props} ref={imageRef} style={{ ...style, objectPosition }} />
    }
    

    Thankfully that part was really simple. I'm sure you noticed we still need to implement this useHotspot hook that returns the objectPosition property. First I just wanted to address the changes we made to the ImageProps from next/image.

    We added a single property to make it as easy as possible to use. The hotspotPositioning prop optionally accepts both the aspectRatio and the hotspot. Both of these are easily pulled in using the groqImage function.

    { hotspotPositioning?: {
        aspectRatio?: number
        hotspot?: SanityHotspot
    } }
    

    Pitfall

    It is possible that the aspectRatio will not be available if you aren't using the Media plugin for Sanity.


    If you do not provide both of these, the hotspot will not be dynamically applied.

    Okay—the tough part. How exactly does the useHotspot hook calculate the coordinates of the objectPosition property?

    By using a useEffect hook, we are able to update the objectPosition useState each time the width and/or height of the <img> element changes. Before actually running any calculations, we always check whether the hotspot and aspectRatio are provided, so—although if you know you don't need to dynamically position the hotspot, you shouldn't use this component—it shouldn't hurt performance if you don't have either of those.

    The containerAspectRatio is the aspect ratio of the part of the image that is actually visible. By comparing this to the aspectRatio, which is the full image, we can know which sides the image is being cropped on by the container.

    By default we use the x and y coordinates of the hotspot for the objectPosition, in the case the hotspot isn't being cutoff at all..

    Regardless of whether the image is being cropped vertically or horizontally the calculation is basically the same. First, it calculates the aspect ratio of the visible area and it uses the result to determine how far off the overflow is on both sides, in a decimal format (0-1). Next, it calculates how far off—if at all—the hotspot bound overflow. By comparing each respective side's overflow to its hotspot overflowing side counterpart, we are able to determine what direction the objectPosition needs to move.

    It's important to note that objectPosition does not move the image the same way using top, left, or translate does. Where positive values move the image down and/or right and negative values move the image up and/or left, objectPosition moves the image within its containing dimensions. This means—assuming we start at 50% 50%—making the value lower moves the image right or down respectively, and making the value higher moves the image left or up respectively. This is an inverse from the other positioning properties, and objectPosition doesn't use negative values (at least not for how we want to use it). This is why the calculations are {x or y} ± ({total overflow amount} - {hotspot overflow amount}).

    Lastly, we have the situation where two sides are overflowing. In this case we want to balance how much each side is overflowing to find a middle ground. This is simply 2 * {x or y} - 0.5.

    Once calculations are made, we convert the numbers to a percentage with a min max statement to make sure it never gets inset.

    function useHotspot({
        aspectRatio,
        hotspot,
        imageRef,
    }: {
        aspectRatio?: number
        hotspot?: SanityHotspot
        imageRef?: RefObject<HTMLImageElement | null>
    }) {
        const [objectPosition, setObjectPosition] = useState('50% 50%'),
            { width, height } = useResize(imageRef)
    
        useEffect(() => {
            if (hotspot && aspectRatio) {
                const containerAspectRatio = width / height
    
                const { height: hotspotHeight, width: hotspotWidth, x, y } = hotspot
    
                let positionX = x,
                    positionY = y
    
                if (containerAspectRatio > aspectRatio) {
                    // Container is wider than the image (proportionally)
                    // Image will be fully visible horizontally, but cropped vertically
    
                    // Calculate visible height ratio (what portion of the image height is visible)
                    const visibleHeightRatio = aspectRatio / containerAspectRatio
    
                    // Calculate the visible vertical bounds (in normalized coordinates 0-1)
                    const visibleTop = 0.5 - visibleHeightRatio / 2,
                        visibleBottom = 0.5 + visibleHeightRatio / 2
    
                    const hotspotTop = y - hotspotHeight / 2,
                        hotspotBottom = y + hotspotHeight / 2
    
                    // Hotspot extends above the visible area, shift it down
                    if (hotspotTop < visibleTop) positionY = y - (visibleTop - hotspotTop)
    
                    // Hotspot extends below the visible area, shift it up
                    if (hotspotBottom > visibleBottom) positionY = y + (hotspotBottom - visibleBottom)
    
                    // Hotspot extends above and below the visible area, center it vertically
                    if (hotspotTop < visibleTop && hotspotBottom > visibleBottom) positionY = 2 * y - 0.5
                } else {
                    // Container is taller than the image (proportionally)
                    // Image will be fully visible vertically, but cropped horizontally
    
                    // Calculate visible width ratio (what portion of the image width is visible)
                    const visibleWidthRatio = containerAspectRatio / aspectRatio
    
                    // Calculate the visible horizontal bounds (in normalized coordinates 0-1)
                    const visibleLeft = 0.5 - visibleWidthRatio / 2,
                        visibleRight = 0.5 + visibleWidthRatio / 2
    
                    const hotspotLeft = x - hotspotWidth / 2,
                        hotspotRight = x + hotspotWidth / 2
    
                    // Hotspot extends to the left of the visible area, shift it right
                    if (hotspotLeft < visibleLeft) positionX = x - (visibleLeft - hotspotLeft)
    
                    // Hotspot extends to the right of the visible area, shift it left
                    if (hotspotRight > visibleRight) positionX = x + (hotspotRight - visibleRight)
    
                    // Hotspot extends beyond the visible area on both sides, center it
                    if (hotspotLeft < visibleLeft && hotspotRight > visibleRight) positionX = 2 * x - 0.5
                }
    
                positionX = Math.max(0, Math.min(1, positionX))
                positionY = Math.max(0, Math.min(1, positionY))
    
                setObjectPosition(`${positionX * 100}% ${positionY * 100}%`)
            }
        }, [aspectRatio, hotspot, width, height])
    
        return { objectPosition }
    }
    

    Complete component

    'use client'
    
    // * Types
    import { SanityHotspot } from '@/typings/sanity'
    
    export type ImgProps = ImageProps & { hotspotPositioning?: { aspectRatio?: number; hotspot?: SanityHotspot } }
    
    // * React
    import { RefObject, useEffect, useRef, useState } from 'react'
    
    // * Hooks
    import { useResize } from '@/hooks/use-resize'
    
    // * Components
    import Image, { ImageProps } from 'next/image'
    
    function useHotspot({
        aspectRatio,
        hotspot,
        imageRef,
    }: {
        aspectRatio?: number
        hotspot?: SanityHotspot
        imageRef?: RefObject<HTMLImageElement | null>
    }) {
        const [objectPosition, setObjectPosition] = useState('50% 50%'),
            { width, height } = useResize(imageRef)
    
        useEffect(() => {
            if (hotspot && aspectRatio) {
                const containerAspectRatio = width / height
    
                const { height: hotspotHeight, width: hotspotWidth, x, y } = hotspot
    
                let positionX = x,
                    positionY = y
    
                if (containerAspectRatio > aspectRatio) {
                    // Container is wider than the image (proportionally)
                    // Image will be fully visible horizontally, but cropped vertically
    
                    // Calculate visible height ratio (what portion of the image height is visible)
                    const visibleHeightRatio = aspectRatio / containerAspectRatio
    
                    // Calculate the visible vertical bounds (in normalized coordinates 0-1)
                    const visibleTop = 0.5 - visibleHeightRatio / 2,
                        visibleBottom = 0.5 + visibleHeightRatio / 2
    
                    const hotspotTop = y - hotspotHeight / 2,
                        hotspotBottom = y + hotspotHeight / 2
    
                    // Hotspot extends above the visible area, shift it down
                    if (hotspotTop < visibleTop) positionY = y - (visibleTop - hotspotTop)
    
                    // Hotspot extends below the visible area, shift it up
                    if (hotspotBottom > visibleBottom) positionY = y + (hotspotBottom - visibleBottom)
    
                    // Hotspot extends above and below the visible area, center it vertically
                    if (hotspotTop < visibleTop && hotspotBottom > visibleBottom) positionY = 2 * y - 0.5
                } else {
                    // Container is taller than the image (proportionally)
                    // Image will be fully visible vertically, but cropped horizontally
    
                    // Calculate visible width ratio (what portion of the image width is visible)
                    const visibleWidthRatio = containerAspectRatio / aspectRatio
    
                    // Calculate the visible horizontal bounds (in normalized coordinates 0-1)
                    const visibleLeft = 0.5 - visibleWidthRatio / 2,
                        visibleRight = 0.5 + visibleWidthRatio / 2
    
                    const hotspotLeft = x - hotspotWidth / 2,
                        hotspotRight = x + hotspotWidth / 2
    
                    // Hotspot extends to the left of the visible area, shift it right
                    if (hotspotLeft < visibleLeft) positionX = x - (visibleLeft - hotspotLeft)
    
                    // Hotspot extends to the right of the visible area, shift it left
                    if (hotspotRight > visibleRight) positionX = x + (hotspotRight - visibleRight)
    
                    // Hotspot extends beyond the visible area on both sides, center it
                    if (hotspotLeft < visibleLeft && hotspotRight > visibleRight) positionX = 2 * x - 0.5
                }
    
                positionX = Math.max(0, Math.min(1, positionX))
                positionY = Math.max(0, Math.min(1, positionY))
    
                setObjectPosition(`${positionX * 100}% ${positionY * 100}%`)
            }
        }, [aspectRatio, hotspot, width, height])
    
        return { objectPosition }
    }
    
    export default function Img({ hotspotPositioning, style, ...props }: ImgProps) {
        const imageRef = useRef<HTMLImageElement>(null),
            { objectPosition } = useHotspot({ ...hotspotPositioning, imageRef })
    
        return <Image {...props} ref={imageRef} style={{ ...style, objectPosition }} />
    }
    

    Happy coding :)

    I hope this is helpful for people, as I have been trying to find a solid way to implement this for far too long. If this was helpful to you or you have any recommendations to make it better, please let me know!