Skip to content

Instantly share code, notes, and snippets.

@good-idea
Last active July 29, 2021 23:34
Show Gist options
  • Save good-idea/2bdc0fa697f4d398d2799e2cd82ee1b3 to your computer and use it in GitHub Desktop.
Save good-idea/2bdc0fa697f4d398d2799e2cd82ee1b3 to your computer and use it in GitHub Desktop.
React SanityImage Component

SanityImage

This is a handy component for rendering responsive Sanity images. It has a simple but flexible API -- all you need to do is pass in a Sanity image asset and it will:

  • Generate two srcSets - one in the original file format, another in .webp.
  • Lazy load the image only when its container is within the viewport
  • Provies a simpler way to write a resposive sizes attribute without having to type in your media queries.
  • Lighthouse benefits:
    • Images are properly sized and use webp when available
    • Lazy-loaded images mean faster load times
    • The RatioPadding component ensures that there is no layout shift once the image loads

Plus much more, look at the ImageProps definition for more info!

Example

This takes care of all of its src, srcSet and sizes parsing within the component. Instead of using something like sanityImageUtil every time you make an <Img />, you can just pass in the image data and the rest will be taken care of. For instance, this is how images are rendered in Tablet:

<Img
  className="...the class names..."
  alt={Language.t('Global.editorImageAlt', { name })}
  sizes="(maxWidth: 768px) 400px, (maxWidth: 1080px) 300px, 300px"
  src={sanityImgUtil(teamMember.avatarImage, 400)}
  srcSet={sanityImgSrcSetUtil(
    teamMember.avatarImage,
    400,
    300,
    300
  )}
  dimensions={
    teamMember.avatarImage.metadata &&
    teamMember.avatarImage.metadata.dimensions
  }
  crop={teamMember.avatarImage.crop}
/>
<Img
  className="...the class names..."
  alt={Language.t('Global.editorImageAlt', { name })}
  sizes="(maxWidth: 768px) 400px, (maxWidth: 1080px) 300px, 300px"
  src={sanityImgUtil(teamMember.avatarImage, 400)}
  srcSet={sanityImgSrcSetUtil(
    teamMember.avatarImage,
    400,
    300,
    300
  )}
/>

With this component, we can simplify all of this and make images across the site more consistent. (It's easy to forget to use sanityImgUtil every time!)

<SanityImage
  className="...the class names..."
  alt={Language.t('Global.editorImageAlt', { name })}
  sizes={['400px', '300px']}
  image={teamMember.avatarImage}
/>
<SanityImage
  className="...the class names..."
  alt={Language.t('Global.editorImageAlt', { name })}
  sizes={['400px', '300px']}
  image={teamMember.avatarImage}
/>

Notes

  • if you're using Next.js, their next/image component takes care of (most) of this on its own, you don't need to use any of this!
    • Oops! Actually, next/image only does this for statically imported images, i.e. images that are in your public directory.
  • To get the aspect ratio, be sure to include the asset's metadata in your Groq query. _To reduce network request payloads, be sure to request only metadata.dimensions. Sanity image metadata also includes a fairly large palette object, as well as a LQIP - a low quality image preview, that is simply image data stored in a string.
    • _however, if you want to display a blurry version of the image before the real one loads, include the LQIP!
  • Edit the breakpoint sizes in constants.ts to match the breakpoints of your project.
  • There are two hooks included that are useful in other circumstances, see useInViewport.ts and useStatefulRef.ts.
  • You'll need to do a little bit of plumbing to set this up in your project - such as defining an Image type, or swapping out styled-components for a different styling system that you use.
  • tip: make the sizes prop required to ensure you're making these images as small & responsive as possible

A Sanity Tip:

Instead of using Sanity's default image object type in your schema definitions, roll your own and give it a name like richImage. This way you can add additional fields (like alt, caption, or anything else) and those changes will go into effect wherever you use richImage. If you had just used image, you would have to add all of those fields to each instance within your schema! Even if you don't plan on adding additional fields, it's still preferable to set up your own richImage to give you the flexiblity of extending it in the future.

Todo

  • write some tests
  • Make it generic & styling-system agnostic. A simple CSS import should replace styled-components.
  • Publish to NPM package
  • Remove the caption rendering, since not all projects will include captions in their images. It will be easy enough to make your own <Image /> wrapper component that implements this and renders captions or whatever else.
/**
* The media queries for your site's breakpoints
*
* You do not need to add the largest breakpoint,
* as the default size will be used in that context.
*/
export const BREAKPOINT_QUERIES = [
'(max-width: 550px)', // mobile
'(max-width: 850px)', // tablet
];
import * as React from 'react';
import { RatioImageFill } from './styled';
interface RatioPaddingProps {
ratio: number;
canvasFill?: boolean;
backgroundColor?: string;
}
export const RatioPadding = ({
ratio,
canvasFill,
backgroundColor: customBGColor,
}: RatioPaddingProps) => {
const [src, setSrc] = React.useState<string | void>(undefined);
const backgroundColor = customBGColor || 'transparent';
React.useEffect(() => {
if (!canvasFill) return;
const canvas = window.document.createElement('canvas');
canvas.setAttribute('width', '1600');
canvas.setAttribute('height', `${1600 * ratio}`);
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.beginPath();
ctx.rect(0, 0, 1600, 1600 * ratio);
ctx.fillStyle = backgroundColor || 'rgba(220, 220, 220, 0)';
ctx.fill();
const srcData = canvas.toDataURL('image/png');
setSrc(srcData);
}, [ratio, canvasFill, backgroundColor]);
const paddingBottom = src ? 0 : `${ratio * 100}%`;
return (
<RatioImageFill style={{ paddingBottom, backgroundColor }} aria-hidden>
{src ? <img src={src} /> : null}
</RatioImageFill>
);
};
import * as React from 'react';
import { RichImage } from '../../types';
import { useStatefulRef, useInViewport } from '../../utils';
import { getSizes, getImageDetails, getAspectRatio } from './utils';
import { RatioPadding } from './RatioPadding';
import { HoverImageWrapper, Wrapper, Picture, Caption } from './styled';
interface ImageProps {
className?: string;
image: RichImage;
altText: string;
hoverImage?: RichImage;
/**
* A custom ratio for this image (height / width)
*
* If your GROQ fetches the image's metadata, that will be used to determine
* the default ratio.
*/
ratio?: number;
/**
* A css object-fit property. defaults to 'cover'.
*
* If you are using the default ratio, this prop is not necessary.
*/
objectFit?: string;
/**
* Set to `true` if you want to use HTML canvas
* to render the placeholder. This is only necessary when
* the default usage of a container with padding-bottom
* produces undesired reuslts.
*/
canvasFill?: boolean;
/**
* An optional color to use as the background of the image container.
* This is only visible before the image loads.
* Defaults to 'transparent'
*/
backgroundColor?: string;
/**
* The css/html sizes at which this image is expected to appear,
* from mobile to desktop. The final value will be used without a breakpoint.
*
* Examples:
*
* ['100vw', '80vw', '500px'] =>
* '(max-width: 650px) 100vw, (max-width: 900px) 80vw, 500px'
*
* ['100vw', '80vw'] =>
* '(max-width: 650px) 100vw, 80vw'
*
* ['80vw'] =>
* '80vw'
*/
sizes: string[];
/**
* An optional onLoad handler
*/
onLoad?: () => void;
/**
* Set to `true` if you do not want the image to lazy-load
*/
preload?: boolean;
}
/**
* Create's an image "sizes" attribute given an array of numbers.
*/
export const SanityImage: React.FC<ImageProps> = ({
image,
className,
sizes: imageSizes,
hoverImage,
altText: customAltText,
onLoad,
preload,
ratio: customRatio,
canvasFill,
backgroundColor,
objectFit,
}) => {
const [loaded, setLoaded] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
const imageRef = useStatefulRef<HTMLImageElement>(null);
const { isInViewOnce } = useInViewport(containerRef);
const imageDetails = React.useMemo(() => getImageDetails(image), [image]);
const hoverImageDetails = React.useMemo(
() => (hoverImage ? getImageDetails(hoverImage) : null),
[hoverImage],
);
const sizes = getSizes(imageSizes);
const {
caption,
src,
altText: cmsAltText,
srcSet,
srcSetWebp,
} = imageDetails || {};
const altText = customAltText || cmsAltText;
React.useEffect(() => {
if (imageRef.current === null) return;
if (imageRef.current.complete) {
setLoaded(true);
}
}, [imageRef.current]);
const handleOnLoad = () => {
setLoaded(true);
if (onLoad) onLoad();
};
const ratio = customRatio || getAspectRatio(image);
const shouldRender = preload || isInViewOnce;
return (
<Wrapper ref={containerRef} className={className}>
{ratio ? (
<RatioPadding
canvasFill={canvasFill}
backgroundColor={backgroundColor}
ratio={ratio}
/>
) : null}
{src && shouldRender ? (
<Picture objectFit={objectFit} loaded={loaded}>
{srcSetWebp ? (
<source type="image/webp" srcSet={srcSetWebp} sizes={sizes} />
) : null}
{srcSet ? (
<source type="image/jpg" srcSet={srcSet} sizes={sizes} />
) : null}
<img
src={src}
alt={altText || ''}
ref={imageRef}
onLoad={handleOnLoad}
rel={preload ? 'preload' : undefined}
/>
{hoverImageDetails && hoverImageDetails.src ? (
<HoverImageWrapper>
<img
src={hoverImageDetails.src}
sizes={sizes}
srcSet={srcSetWebp || srcSet || undefined}
/>
</HoverImageWrapper>
) : null}
{caption ? <Caption>{caption}</Caption> : null}
</Picture>
) : null}
</Wrapper>
);
};
import styled, { DefaultTheme, css } from 'styled-components';
export const HoverImageWrapper = styled.div`
> img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
transition: 0.3s;
}
`;
export const Wrapper = styled.div`
position: relative;
text-align: left;
width: 100%;
&:hover ${HoverImageWrapper} {
opacity: 1;
}
`;
export const Caption = styled.h6``;
interface PictureProps {
theme: DefaultTheme;
loaded: boolean;
objectFit?: string;
}
export const Picture = styled.picture`
${({ loaded, objectFit }: PictureProps) => css`
max-height: 100%;
max-width: 100%;
width: auto;
background-color: transparent;
display: block;
& > img {
opacity: ${loaded ? 1 : 0};
transition: 0.3s;
transition-delay: 0.3s;
max-width: 100%;
object-fit: ${objectFit || 'cover'};
display: block;
}
`}
`;
export const PreloadWrapper = styled.div`
position: fixed;
top: -500px;
left: -500px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
z-index: -100;
`;
export const RatioImageFill = styled.div`
display: block;
& + picture > img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
`;
import * as React from 'react';
const { useEffect, useReducer, useRef } = React;
// Adapted from:
// https://medium.com/the-non-traditional-developer/how-to-use-an-intersectionobserver-in-a-react-hook-9fb061ac6cb5
interface State {
isInView: boolean;
isInViewOnce: boolean;
}
const INTERSECTION = 'INTERSECTION';
interface IsInViewAction {
type: typeof INTERSECTION;
isIntersecting: boolean;
}
type Action = IsInViewAction;
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case INTERSECTION:
return {
isInViewOnce: state.isInViewOnce || action.isIntersecting,
isInView: action.isIntersecting,
};
default:
throw new Error(`"${action.type}" is not a valid action`);
}
};
const initialState: State = {
isInView: false,
isInViewOnce: false,
};
export const useInViewport = (
node: React.RefObject<HTMLElement>,
rootMargin?: string,
) => {
const [state, dispatch] = useReducer(reducer, initialState);
const observer = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (observer?.current) observer.current.disconnect();
const options = {
rootMargin: rootMargin || '200px 400px',
};
observer.current = new IntersectionObserver(([entry]) => {
const { isIntersecting } = entry;
dispatch({ type: INTERSECTION, isIntersecting });
}, options);
const { current: currentObserver } = observer;
if (node.current) currentObserver.observe(node.current);
return () => currentObserver.disconnect();
}, [node.current]);
return state;
};
import { useState, useRef } from 'react';
interface StatefulRef<T> {
current: T | null;
}
/**
* React's useRef returns a mutable reference to the ref element.
* This means that you can't use it as a dependency in functions like
* useEffect.
*
* This hook acts the same as `useRef`, except it will trigger a state
* update when the ref has been assigned.
*/
/* Copied from: https://github.com/Bedrock-Layouts/Bedrock/blob/90e564191d9e661d60af1f2d99e3b68fa6330c78/packages/use-stateful-ref/src/index.tsx */
export function useStatefulRef<T extends HTMLElement = HTMLElement>(
initialVal = null,
) {
// eslint-disable-next-line prefer-const
let [cur, setCur] = useState<T | null>(initialVal);
const { current: ref } = useRef({
current: cur,
});
Object.defineProperty(ref, 'current', {
get: () => cur as T,
set: (value: T) => {
if (!Object.is(cur, value)) {
cur = value;
setCur(value);
}
},
});
return ref as StatefulRef<T>;
}
import imageUrlBuilder from '@sanity/image-url';
import { Maybe, RichImage } from '../../types';
import { config } from '../../config';
import { BREAKPOINT_QUERIES } from './constants';
const { projectId, dataset } = config.sanity;
const builder = imageUrlBuilder({
projectId,
dataset,
});
export interface ImageDetails {
src: string | null | void;
altText?: string | null;
srcSet?: string | null;
srcSetWebp?: string | null;
caption?: string | null;
}
interface ImageWidth {
width: number;
src: Maybe<string>;
}
const buildSrcSet = (widths: ImageWidth[]): string =>
widths.map(({ src, width }) => `${src} ${width}w`).join(', ');
/**
* The default sizes used to create the srcSet
*/
const defaultSrcsetSizes: number[] = [100, 300, 600, 800, 1200, 1600, 2200];
export const getImageDetails = (
image: RichImage,
sizes: number[] = defaultSrcsetSizes,
): ImageDetails | null => {
if (!image?.asset) return null;
const source = builder.image(image);
const src = source.url();
const srcSet = buildSrcSet(
sizes.map((size) => {
return {
width: size,
src: source.width(size).url(),
};
}),
);
const srcSetWebp = buildSrcSet(
sizes.map((size) => {
return {
width: size,
src: source.width(size).format('webp').url(),
};
}),
);
const { altText } = image;
const caption = image._type === 'richImage' ? image?.caption : undefined;
return { caption, src, srcSet, srcSetWebp, altText };
};
const defaultCrop = {
bottom: 0,
left: 0,
right: 0,
top: 0,
};
export const getAspectRatio = (
image?: RichImage | null | void,
): number | void => {
if (!image) return undefined;
const dimensions = image.asset?.metadata?.dimensions;
if (!dimensions) return undefined;
const crop = image.crop ?? defaultCrop;
const { width, height } = dimensions;
if (!width || !height) {
return undefined;
}
const { left, right, bottom, top } = crop;
if (
left === null ||
left === undefined ||
right === null ||
right === undefined ||
bottom === null ||
bottom === undefined ||
top === null ||
top === undefined
) {
return height / width;
}
const w = width * (1 - left - right);
const h = height * (1 - bottom - top);
const aspectRatio = h / w;
return aspectRatio;
};
export const getSizes = (sizes: string[]): string =>
sizes
.reduce<string[]>((prevSizes, size, index) => {
const isLast = index === sizes.length - 1;
const sizeString = isLast ? size : `${BREAKPOINT_QUERIES[index]} ${size}`;
return [...prevSizes, sizeString];
}, [])
.join(', ');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment