Skip to content

Instantly share code, notes, and snippets.

@mckhames
Forked from TheThirdRace/Image.tsx
Created June 11, 2021 14:54
Show Gist options
  • Save mckhames/1e82f5e18633228c03e66f30b459a39a to your computer and use it in GitHub Desktop.
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
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: {}
}
/**
* # `<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} />
</>
)
}
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