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>