Last active
April 1, 2021 17:47
-
-
Save hdoro/7a3d263d72b936eddcf1faee1b5a31d0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Same interface as above | |
import { SanityImage } from './LazyImage' | |
// Get the @sanity/image-url builder from wherever you have your client | |
import { imageBuilder } from '../../utils/client' | |
const DEFAULT_MAX_WIDTH = 1200 | |
const MINIMUM_WIDTH = 100 | |
const WIDTH_STEPS = 200 | |
const MAX_MULTIPLIER = 3 | |
function getImageProps(props: { | |
image?: SanityImage | |
maxWidth?: number | |
sizes?: string | |
}): | |
| { | |
src: string | |
aspectRatio: number | |
srcset?: string | |
sizes?: string | |
} | |
| undefined { | |
const { image } = props | |
if (!image?.asset?._ref) { | |
return | |
} | |
// example asset._ref: | |
// image-7558c4a4d73dac0398c18b7fa2c69825882e6210-366x96-png | |
// When splitting by '-' we can extract the dimensions and format | |
const [, , dimensions, format] = image.asset._ref.split('-') | |
// Dimensions come as 366x96 (widthXheight), so we split it into an array and | |
// transform each entry into actual numbers instead of strings | |
const [srcWidth, srcHeight] = dimensions | |
.split('x') | |
.map((num) => parseInt(num, 10)) | |
const aspectRatio = srcWidth / srcHeight | |
// We want to preserve SVGs as they're usually the most compact and lossless format, so if the original image is an svg return only its src and the component won't have a srcset | |
if (format === 'svg') { | |
return { | |
src: imageBuilder.image(image).url(), | |
aspectRatio, | |
} | |
} | |
// We can either set a custom `sizes` property or consider the maximum size | |
// of containers, which is 1200px for this project. We're not going to have | |
// fullscreen images, so the maximum size they'll have is that of the | |
// container, unless specified otherwise | |
const maxWidth = props.maxWidth || DEFAULT_MAX_WIDTH | |
const finalSizes = | |
props.sizes || `(max-width: ${maxWidth}px) 100vw, ${maxWidth}px` | |
let srcset = '' | |
// total number of variations is based on the number of steps | |
// we need to go from minimum width to maxWidth * 3 (retina) | |
// Minimum number of variations is 3, hence Math.max | |
const totalVariations = Math.max( | |
Math.ceil((maxWidth * MAX_MULTIPLIER - MINIMUM_WIDTH) / WIDTH_STEPS), | |
3, | |
) | |
// Get the middle variation and use it as the default width | |
const defaultWidth = | |
MINIMUM_WIDTH + Math.floor(totalVariations / 2) * WIDTH_STEPS | |
// Which is going to be used as the default src | |
const src = imageBuilder | |
.image(image) | |
.auto('format') | |
.width(defaultWidth) | |
.fit('max') | |
.url() | |
for (let i = 0; i < totalVariations; i++) { | |
const currWidth = MINIMUM_WIDTH + WIDTH_STEPS * i | |
// Add this width to both srcsets (webp and non-webp) | |
srcset = `${srcset ? `${srcset},` : ''} ${imageBuilder | |
.image(image) | |
.auto('format') | |
.width(currWidth) | |
.fit('max') | |
.url()} ${currWidth}w` | |
} | |
return { | |
src, | |
sizes: finalSizes, | |
aspectRatio, | |
srcset, | |
} | |
} | |
export default getImageProps |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.lazy-img { | |
position: relative; | |
overflow: hidden; | |
img { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
object-fit: cover; | |
} | |
} | |
.lazy-img__img { | |
transition: $t-slow opacity; | |
opacity: 0; | |
&_loaded { | |
opacity: 1; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from "react"; | |
import { useInView } from "react-intersection-observer"; | |
import getImageProps from "./getImageProps"; | |
// Assuming you don't expand the image's asset reference | |
export interface SanityImage { | |
_type?: "image"; | |
alt?: string; | |
caption?: string; | |
asset: { | |
_type: "reference"; | |
_ref: string; | |
metadata?: { | |
lqip?: string; | |
}; | |
}; | |
} | |
const LazyImage: React.FC<{ | |
image: SanityImage; | |
maxWidth?: number; | |
sizes?: string; | |
alt?: string; | |
className?: string; | |
/** | |
* Pass eager if an image above-the-fold for better UX. | |
*/ | |
loading?: "eager" | "lazy"; | |
}> = ({ image, alt, maxWidth, sizes, className, loading = "lazy" }) => { | |
const [inViewRef, inView] = useInView({ | |
triggerOnce: true, | |
threshold: 0, | |
rootMargin: `200px 0px`, | |
}); | |
const [isLoaded, setLoaded] = React.useState(false); | |
// When an image is in the browser cache or is completed loading before react rehydration, | |
// the `onload` may not be triggered. In order to ensure we have the correct "complete" | |
// state, check the `complete` property after mounting | |
const imgRef = React.createRef(); | |
React.useEffect(() => { | |
if (imgRef?.current?.complete && imgRef?.current?.naturalWidth) { | |
setLoaded(true); | |
} | |
}, [imgRef]); | |
if (!image?.asset?._ref) { | |
return null; | |
} | |
const altText = alt || image.alt; | |
const imgProps = getImageProps({ | |
image, | |
maxWidth, | |
sizes, | |
}); | |
if (!imgProps?.src || !imgProps?.aspectRatio) { | |
return null; | |
} | |
// Whether or not to show the image | |
const showImage = loading === "eager" || inView; | |
const Image = React.useMemo( | |
() => ( | |
<div | |
ref={inViewRef} | |
className={`lazy-img ${className || ""}`} | |
style={ | |
{ | |
"--img-ratio": imgProps.aspectRatio, | |
} as any | |
} | |
> | |
{/* Spacer div that keeps the container height according to the image before it loads to avoid layout shifts */} | |
<div | |
style={{ | |
width: "100%", | |
paddingBottom: `${100 / imgProps.aspectRatio}%`, | |
}} | |
aria-hidden="true" | |
className="lazy-img__spacer" | |
/> | |
{showImage ? ( | |
<picture> | |
{/* If we have a singleSrc defined, such as in FourOhFour, that means we don't need image variations, so these sources are unnecessary */} | |
{imgProps.srcset && <source srcSet={imgProps.srcset} sizes={sizes} />} | |
<img | |
sizes={sizes} | |
className={"lazy-img__img"} | |
onLoad={() => setLoaded(true)} | |
style={{ opacity: isLoaded ? 1 : 0 }} | |
ref={imgRef} | |
alt={alt} | |
/> | |
</picture> | |
) : null} | |
{/* noscript for users without JS activated */} | |
<noscript> | |
<img | |
srcSet={imgProps.srcset} | |
src={imgProps.src} | |
alt={altText || ""} | |
sizes={imgProps.sizes} | |
/> | |
</noscript> | |
</div> | |
), | |
[imgProps, showImage] | |
); | |
if (!image.caption) { | |
return Image; | |
} | |
return ( | |
<figure> | |
{Image} | |
<figcaption className="text-mono">{image.caption}</figcaption> | |
</figure> | |
); | |
}; | |
export default LazyImage; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment