Skip to content

Instantly share code, notes, and snippets.

@hdoro
Last active April 1, 2021 17:47
Show Gist options
  • Save hdoro/7a3d263d72b936eddcf1faee1b5a31d0 to your computer and use it in GitHub Desktop.
Save hdoro/7a3d263d72b936eddcf1faee1b5a31d0 to your computer and use it in GitHub Desktop.
// 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
.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;
}
}
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