-
-
Save mckhames/1e82f5e18633228c03e66f30b459a39a to your computer and use it in GitHub Desktop.
Merge of Chakra-ui and NextJs Image component to remove most of pain points
This file contains 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 { StyleConfig } from '../../type/Chakra' | |
import { articleInnerSpacing } from './Article' | |
export const Image: StyleConfig = { | |
parts: ['layPicture', 'layImage', 'preImage'], | |
baseStyle: { | |
layPicture: {}, | |
layImage: { | |
// layout | |
display: 'inline-block' | |
}, | |
preImage: { | |
// box model | |
height: 'auto', | |
maxWidth: 'inherit', | |
width: 'auto' | |
// misc | |
// ! not activated because it cause jumpiness while scrolling up in Chrome | |
// contentVisibility: 'auto' | |
// containIntrinsicSize: 'width height' // obviously need to be adjusted | |
} | |
}, | |
variants: { | |
fixed100: { | |
// example of variant that activates `<picture>` wrapper through theme | |
layPicture: { | |
width: '100px', | |
height: '100px' | |
}, | |
layImage: {}, | |
preImage: {} | |
}, | |
cumulativeLayoutShift: { | |
layPicture: { | |
// layout | |
display: 'block', // necessary for firefox | |
position: 'relative', | |
// box model | |
boxSizing: 'border-box', | |
// misc | |
contentVisibility: 'auto' | |
}, | |
layImage: { | |
// layout | |
display: 'block', // necessary for firefox | |
inset: 0, | |
position: 'absolute' | |
}, | |
preImage: { | |
// box model | |
maxHeight: '100%', | |
maxWidth: '100%', | |
minHeight: '100%', | |
minWidth: '100%' | |
} | |
} | |
}, | |
defaultProps: {} | |
} |
This file contains 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
/** | |
* # `<Image>` | |
* | |
* This component is a merge between `next/image` and `Chakra-ui`. | |
* | |
* ## Pros | |
* | |
* - Use NextJs backend solution so you get on the fly image optimization | |
* - Offer the same optimizations as `next/image` (lazy loading, priority, async decoding, no CLS, etc.) | |
* - Use Chakra's theme (`variant`) so you have full control of styling | |
* - No more nightmares trying to style `next/image` wrappers | |
* - No more bloating the DOM with multiple wrappers per image | |
* - `<img>` is back to `display: inline-block` by default | |
* - All icons are served automatically through the `1x` or `2x` pixel density optimization | |
* - Passing `sizesMax={0}` can force a bigger image to be served in this mode | |
* - All images are served automatically through an `srcset` auto-build function | |
* - Load configs through `NextJs` config | |
* - No more fiddling trying to build an `sizes` manually | |
* - Simply pass `sizesMax={ImageMaxWidth}` or don't pass `sizesMax` at all (defaults to highest possible value) | |
* - `sizesMax` allows you to limit image width according to your design, not the viewport | |
* - No more loading a 3840px wide image on a 4K screen when your `main` section is 1200px | |
* - Use semantic HTML tags unlike `next/image` | |
* - `<img>` is used for the image | |
* - `<picture>` is used for the wrapper/container (optional) | |
* - No big red warning in the console about using `<div>` in inline tags | |
* - `height` & `width` are recommended, not mandatory | |
* - Can use a low quality image placeholder (lquip) for better user experience | |
* - `loader` function allow to build the final `src` url, so you can support many image providers | |
* - Possible to use with a **secure** `Content-Security-Policy` header | |
* - Extra performance by using `content-visibility: auto` on the `<picture>` wrapper | |
* - Not available by default on `<img>` to avoid scrolling up issues on Chrome | |
* - Could be added manually on `<img>` through styles if wanted | |
* - About 400 lines of code less than `next/image` solution | |
* | |
* ## Cons | |
* | |
* - Doesn't support Chakra's inline styling (by personal choice, could easily be added) | |
* - Use native `loading=lazy`, meaning the feature isn't supported for all browsers | |
* - Using `<img>` without it's wrapper (`<picture>`) will give a very low CLS instead of none (ex: 0.03) | |
* - Low quality image placeholder (lquip) will consume more data overall, but can give a better user experience | |
* - Serving "responsive" images can increase data consuption, but this should be negligible because: | |
* - Images are optimized to a low size to begin with | |
* - Those most affected are users with big screens, which usually don't mind more data | |
* - Users don't resize their browser window non-stop | |
* - Still use `NextJs` cache mechanism (max-age: 0, must-revalidate, etag) | |
* - This could be mitigated by using Cloudflare workers | |
* | |
* ## Tips & Tricks | |
* | |
* ### Optimization | |
* | |
* - Pass `width` & `height` whenever you can, it's the biggest optimization you're gonna get out of the box | |
* | |
* ### `<picture>` wrapper | |
* | |
* - Will be added automatically under these circumstances | |
* - Pass `width` & `height` props | |
* - Define a style for Image's `layPicture` part in the theme | |
* - `<picture>` wrapper is mandatory to reach a cumulative layout shift (CLS) of 0 | |
* - You won't be penalized by Google ranking as long as you keep CLS < 0.1, which makes the wrapper "optional" | |
* | |
* ### `sizesMax` | |
* | |
* - Pass `sizesMax={0}` to force an image to be optimized with `srcset` containing `1x, 2x` variants | |
* - Mostly for icons, but you could use this for normal images too | |
* - Don't pass `sizesMax` to force an image to be optimized for the current viewport width | |
* - If an image is less than the full screen's width, you can pass it's max size like this `sizesMax={992}` | |
*/ | |
import { Box, Image as ChakraImage, ThemingProps, useMultiStyleConfig } from '@chakra-ui/react' | |
import Head from 'next/head' | |
import { ImageLoader, ImageLoaderProps, ImageProps as NextImageProps } from 'next/image' | |
import React, { ReactElement } from 'react' | |
/** ******************************************************************************************************************* | |
* Types | |
*/ | |
type ImageProps = Pick<NextImageProps, 'loader' | 'priority' | 'src'> & | |
Pick<Required<NextImageProps>, 'alt'> & | |
Partial<Pick<HTMLImageElement, 'height' | 'width'>> & | |
Pick<ThemingProps, 'variant'> & { | |
lquip?: boolean | |
quality?: number | |
sizesMax?: number | |
} | |
type GenerateCumulativeLayoutShiftFixProps = Pick<ImageProps, 'height' | 'sizesMax' | 'width'> | |
type GenerateImageAttributesProps = { | |
loader: ImageLoader | |
quality?: number | |
sizesMax?: number | |
src: string | |
width?: number | |
} | |
type GenerateImageAttributesResult = { | |
src: string | |
srcSet?: string | |
sizes?: string | |
lquipSrc?: string | |
} | |
type GenerateLquipProps = Pick<ImageProps, 'lquip'> & Pick<GenerateImageAttributesResult, 'lquipSrc'> | |
type ImagePriorityProps = Pick<NextImageProps, 'priority'> & GenerateImageAttributesResult | |
type IsLayoutProps = Pick<ImageProps, 'sizesMax' | 'width'> | |
/** ******************************************************************************************************************* | |
* * Image configurations * | |
* https://github.com/vercel/next.js/blob/canary/packages/next/next-server/server/image-config.ts | |
*/ | |
const defaultQuality = 75 | |
const defaultQualityLquip = 1 | |
const imageConfigDefault = { | |
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], | |
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], | |
path: '/_next/image', | |
loader: 'default', | |
domains: [] | |
} | |
const { | |
deviceSizes: configDeviceSizes, | |
imageSizes: configImageSizes, | |
// loader: configLoader, | |
path: configPath | |
// domains: configDomains | |
} = ((process.env.__NEXT_IMAGE_OPTS as unknown) as typeof imageConfigDefault) || imageConfigDefault | |
const configAllSizes = [...configImageSizes, ...configDeviceSizes].sort((a, b) => a - b) | |
/** ******************************************************************************************************************* | |
* * Private functions * | |
*/ | |
const isLayoutFixed = ({ sizesMax, width = configDeviceSizes[configDeviceSizes.length - 1] }: IsLayoutProps) => | |
sizesMax === 0 || width < configDeviceSizes[0] || configImageSizes.some((w) => width === w) | |
const defaultLoader = ({ src, width, quality = defaultQuality }: ImageLoaderProps): string => { | |
return `${configPath}?url=${encodeURIComponent(src)}&w=${width}&q=${quality}` | |
} | |
const generateCumulativeLayoutShiftFix = ({ height, sizesMax, width }: GenerateCumulativeLayoutShiftFixProps) => { | |
let clsFix = {} | |
if (height && width) { | |
clsFix = { | |
aspectRatio: `${width}/${height}`, | |
overflow: 'hidden', | |
...(isLayoutFixed({ sizesMax, width }) | |
? { | |
height: `${height}px`, | |
width: `${width}px` | |
} | |
: { | |
paddingTop: `calc(${height} / ${width} * 100%)` | |
}) | |
} | |
} | |
return clsFix | |
} | |
const generateImgAttributes = ({ | |
loader, | |
quality, | |
sizesMax, | |
src, | |
width = configDeviceSizes[configDeviceSizes.length - 1] | |
}: GenerateImageAttributesProps): GenerateImageAttributesResult => { | |
let imgAttributes: GenerateImageAttributesResult | |
if (src && src.startsWith('data:')) { | |
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs | |
imgAttributes = { src, srcSet: undefined, sizes: undefined, lquipSrc: undefined } | |
} else if (isLayoutFixed({ sizesMax, width })) { | |
const widths = [ | |
...new Set( | |
/** | |
* This means that most OLED screens that say they are 3x resolution, are actually 3x in the green color, | |
* but only 1.5x in the red and blue colors. | |
* | |
* Showing a 3x resolution image in the app vs a 2x resolution image will be visually the same, though the | |
* 3x image takes significantly more data. Even true 3x resolution screens are wasteful as the human eye | |
* cannot see that level of detail without something like a magnifying glass. | |
* | |
* https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/capping-image-fidelity-on-ultra-high-resolution-devices.html | |
*/ | |
[width, width * 2].map( | |
(w) => configAllSizes.find((s) => s >= w) || configAllSizes[configAllSizes.length - 1] | |
) | |
) | |
] | |
imgAttributes = { | |
src: loader({ src, quality, width: widths[1] }), | |
sizes: undefined, | |
srcSet: widths.map((w, i) => `${loader({ src, quality, width: w })} ${i + 1}x`).join(', '), | |
lquipSrc: loader({ src, quality: defaultQualityLquip, width: widths[0] }) | |
} | |
} else { | |
const maxSizes = sizesMax || configDeviceSizes[configDeviceSizes.length - 1] | |
const widths = [...configDeviceSizes.filter((w) => w < maxSizes), maxSizes] | |
imgAttributes = { | |
src: loader({ src, quality, width: widths[widths.length - 1] }), | |
sizes: widths | |
.map((w, i) => { | |
return i < widths.length - 1 ? ` (max-width: ${w}px) ${w}px` : ` ${w}px` | |
}) | |
.join(), | |
srcSet: widths.map((w) => `${loader({ src, quality, width: w })} ${w}w`).join(', '), | |
lquipSrc: loader({ src, quality: defaultQualityLquip, width: widths[0] }) | |
} | |
} | |
return imgAttributes | |
} | |
const generateLquipPlaceholder = ({ lquip, lquipSrc }: GenerateLquipProps) => ({ | |
...(lquip && lquipSrc | |
? { | |
backgroundImage: `url(${lquipSrc})`, | |
backgroundSize: 'cover' | |
} | |
: {}) | |
}) | |
/** ******************************************************************************************************************* | |
* * Components * | |
*/ | |
const ImagePriority = ({ priority = false, sizes, src, srcSet }: ImagePriorityProps): ReactElement | null => { | |
return priority ? ( | |
// Note how we omit the `href` attribute, as it would only be relevant | |
// for browsers that do not support `imagesrcset`, and in those cases | |
// it would likely cause the incorrect image to be preloaded. | |
// | |
// https://html.spec.whatwg.org/multipage/semantics.html#attr-link-imagesrcset | |
<Head> | |
<link | |
as='image' | |
href={srcSet ? undefined : src} | |
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
// @ts-ignore: imagesrcset is not yet in the link element type | |
imagesrcset={srcSet} | |
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | |
// @ts-ignore: imagesizes is not yet in the link element type | |
imagesizes={sizes} | |
key={`__nimg-${src}${srcSet}${sizes}`} | |
rel='preload' | |
/> | |
</Head> | |
) : // eslint-disable-next-line unicorn/no-null | |
null | |
} | |
export const Image = ({ | |
alt, | |
height, | |
loader = defaultLoader, | |
lquip = false, | |
priority = false, | |
quality = defaultQuality, | |
sizesMax, | |
src, | |
variant, | |
width, | |
...chakraInternals | |
}: ImageProps): ReactElement => { | |
// Retrieve styling | |
const { layPicture: layPictureCls, layImage: layImageCls, preImage: preImageCls } = useMultiStyleConfig('Image', { | |
variant: 'cumulativeLayoutShift' | |
}) | |
const { layPicture, layImage, preImage } = useMultiStyleConfig('Image', { variant }) | |
// Do we need a wrapper? | |
const withWrapperFromProps = !!(width && height) | |
const withWrapperFromTheme = !!(layPicture && layPicture.constructor === Object && Object.keys(layPicture).length) | |
const withWrapper = withWrapperFromProps || withWrapperFromTheme | |
// Generate image attributes | |
const { src: imgSrc, srcSet: imgSrcSet, sizes: imgSizes, lquipSrc } = generateImgAttributes({ | |
loader, | |
quality, | |
sizesMax, | |
src, | |
width | |
}) | |
// Image component | |
const img = ( | |
<ChakraImage | |
alt={alt} | |
decoding='async' | |
htmlHeight={height} | |
htmlWidth={width} | |
loading='lazy' | |
sizes={imgSizes} | |
src={imgSrc} | |
srcSet={imgSrcSet} | |
sx={{ | |
// Styles for `<img>` when used with `<picture>` wrapper | |
// if wrapper is activated by theme then `variant` can override styles from wrapper | |
// if wrapper is activated by props then styles from wrapper will override `variant` | |
...(withWrapperFromTheme ? { ...layImageCls, ...preImageCls } : {}), | |
...layImage, | |
...preImage, | |
...(withWrapperFromProps ? { ...layImageCls, ...preImageCls } : {}) | |
}} | |
// eslint-disable-next-line react/jsx-props-no-spreading | |
{...chakraInternals} | |
/> | |
) | |
// will add a `<picture>` wrapper if style is not empty for layPicture part | |
const image = withWrapper ? ( | |
<Box | |
as='picture' | |
sx={{ | |
...generateLquipPlaceholder({ lquip, lquipSrc }), | |
...generateCumulativeLayoutShiftFix({ height, sizesMax, width }), | |
// Styles for `<picture>` wrapper | |
// if wrapper is activated by theme then `variant` can override styles from wrapper | |
// if wrapper is activated by props then styles from wrapper will override `variant` | |
...(withWrapperFromTheme ? layPictureCls : {}), | |
...layPicture, | |
...(withWrapperFromProps ? layPictureCls : {}) | |
}} | |
> | |
{img} | |
</Box> | |
) : ( | |
img | |
) | |
return ( | |
<> | |
{image} | |
<ImagePriority sizes={imgSizes} src={imgSrc} srcSet={imgSrcSet} priority={priority} /> | |
</> | |
) | |
} |
This file contains 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
images: { | |
/** | |
* ! If your content is usually not full screen, example you have a `main` section, make sure | |
* to include the value `deviceSizes` | |
*/ | |
deviceSizes: [320, 480, 640, 750, 828, 992, 1080, 1200, 1440, 1920, 2048, 2560, 3840] | |
}, |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment