|
| 1 | +## Introduction |
| 2 | + |
| 3 | +For the sake of practice and fun let's build a component that displays a random image on mouse click. It looks more fun and interactive than a static hero image. You can see it in action on the Home page of the my website. |
| 4 | + |
| 5 | +This functionality shares some common parts with the image gallery described in the previous article, such as the component hierarchy and including urls in the client. However, it also introduces some new elements, like a proper blur preloader. |
| 6 | + |
| 7 | +## What we will be building |
| 8 | + |
| 9 | +- **Demo:** https://nemanjamitic.com/ |
| 10 | +- **Github repository:** https://github.com/nemanjam/nemanjam.github.io |
| 11 | + |
| 12 | +{% youtube mMlD-0Ixw4c %} |
| 13 | + |
| 14 | +## Component hierarchy |
| 15 | + |
| 16 | +Again, we will use the similar structure `MDX (index.mdx) -> Astro component (ImageRandom.astro) -> React components (ImageRandomReact.jsx and ImageBlurPreloader.jsx)`, and again, the client React components contain the most complexity. |
| 17 | + |
| 18 | +Code (paraphrased): |
| 19 | + |
| 20 | +```tsx |
| 21 | +// src/pages/index.mdx |
| 22 | + |
| 23 | +<ImageRandom /> |
| 24 | + |
| 25 | +// src/components/ImageRandom.astro |
| 26 | + |
| 27 | +<ImageRandomReact {galleryImages} client:load /> |
| 28 | + |
| 29 | +// src/components/react/ImageRandomReact.tsx |
| 30 | + |
| 31 | +<ImageBlurPreloader {...props} /> |
| 32 | +``` |
| 33 | + |
| 34 | +## Responsive image |
| 35 | + |
| 36 | +This is the functionality shared with the image gallery. This time, we’ll use a fixed low-resolution image for the blur effect and a responsive, high-resolution hero image as the main one. The blur and main images will have different resolutions but share the same `16:9` aspect ratio. |
| 37 | + |
| 38 | +The code is as follows: |
| 39 | + |
| 40 | +```ts |
| 41 | +// src/constants/image.ts |
| 42 | + |
| 43 | +export const IMAGE_SIZES = { |
| 44 | + FIXED: { |
| 45 | + // blur image |
| 46 | + BLUR_16_9: { |
| 47 | + width: 64, |
| 48 | + height: 36, |
| 49 | + }, |
| 50 | + // ... |
| 51 | + }, |
| 52 | + RESPONSIVE: { |
| 53 | + // main image |
| 54 | + POST_HERO: { |
| 55 | + widths: [TW_SCREENS.XS, TW_SCREENS.SM, TW_SCREENS.MD, TW_SCREENS.LG], |
| 56 | + sizes: `(max-width: ${TW_SCREENS.XS}px) ${TW_SCREENS.XS}px, (max-width: ${TW_SCREENS.SM}px) ${TW_SCREENS.SM}px, (max-width: ${TW_SCREENS.MD}px) ${TW_SCREENS.MD}px, ${TW_SCREENS.LG}px`, |
| 57 | + }, |
| 58 | + // ... |
| 59 | + }, |
| 60 | +}; |
| 61 | + |
| 62 | +// actual <img /> tag attributes that are generated with the POST_HERO |
| 63 | + |
| 64 | +<img |
| 65 | + sizes="(max-width: 475px) 475px, (max-width: 640px) 640px, (max-width: 768px) 768px, 1024px" |
| 66 | + width="3264" |
| 67 | + height="1836" |
| 68 | + srcset=" |
| 69 | + /_astro/amfi1.Cv2xkJ5B_1Lofkq.webp 475w, |
| 70 | + /_astro/amfi1.Cv2xkJ5B_Oxmi8.webp 640w, |
| 71 | + /_astro/amfi1.Cv2xkJ5B_X0wXS.webp 768w, |
| 72 | + /_astro/amfi1.Cv2xkJ5B_Z1u01H4.webp 1024w |
| 73 | + " |
| 74 | + src="/_astro/amfi1.Cv2xkJ5B_26HGs8.webp" |
| 75 | +/> |
| 76 | + |
| 77 | +// src/libs/gallery/transform.ts |
| 78 | + |
| 79 | +export const heroImageOptions = { |
| 80 | + ...IMAGE_SIZES.RESPONSIVE.POST_HERO, |
| 81 | +}; |
| 82 | + |
| 83 | +// src/libs/gallery/images.ts |
| 84 | + |
| 85 | +export const getHeroImages = async (): Promise<HeroImage[]> => { |
| 86 | + const blur = await getCustomImages(blurImageOptions); |
| 87 | + const hero = await getCustomImages(heroImageOptions); |
| 88 | + |
| 89 | + const heroImages = mergeArrays(blur, hero).map(([blur, hero]) => ({ |
| 90 | + blur: imageResultToImageAttributes(blur), |
| 91 | + hero: imageResultToImageAttributes(hero), |
| 92 | + })); |
| 93 | + |
| 94 | + return heroImages; |
| 95 | +}; |
| 96 | + |
| 97 | +// src/components/ImageRandom.astro |
| 98 | + |
| 99 | +const galleryImages = await getHeroImages(); |
| 100 | +``` |
| 101 | + |
| 102 | +**Responsive main image in action:** |
| 103 | + |
| 104 | +{% youtube _a8j9HeLLnk %} |
| 105 | + |
| 106 | +## Random image in a static website |
| 107 | + |
| 108 | +Again, we have the same situation as in the image gallery. The key point is to include all image urls in the client and execute `getRandomElementFromArray()` in the client React component to display a random image at runtime. If we called the random function on the server, in the Astro component, we would end up with a single image that was randomly picked at build time - which is not what we want. |
| 109 | + |
| 110 | +This is the code: |
| 111 | + |
| 112 | +```tsx |
| 113 | +--- |
| 114 | +// src/components/ImageRandom.astro |
| 115 | + |
| 116 | +const galleryImages = await getHeroImages(); |
| 117 | +--- |
| 118 | + |
| 119 | +{/* include all the images in the client and let the client pick the random image */} |
| 120 | + |
| 121 | +<div {...props}> |
| 122 | + <ImageRandomReact {galleryImages} client:load /> |
| 123 | +</div> |
| 124 | + |
| 125 | +// src/components/react/ImageRandom.tsx |
| 126 | + |
| 127 | +const ImageRandomReact: FC<Props> = ({ galleryImages, className, divClassName, ...props }) => { |
| 128 | + // cache randomized images |
| 129 | + const randomImage = useMemo(() => getRandomElementFromArray(galleryImages), [galleryImages]); |
| 130 | + |
| 131 | + const [image, setImage] = useState(initialImage); |
| 132 | + |
| 133 | + // pick initial random image on mount |
| 134 | + useEffect(() => { |
| 135 | + setImage(randomImage); |
| 136 | + }, [setImage, randomImage]); |
| 137 | + |
| 138 | + // pick random image onClick |
| 139 | + const handleClick = async () => { |
| 140 | + const randomImage = getRandomElementFromArray(galleryImages); |
| 141 | + setImage(randomImage); |
| 142 | + }; |
| 143 | + |
| 144 | + return ( |
| 145 | + <ImageBlurPreloader |
| 146 | + {...props} |
| 147 | + blurAttributes={{ ...image.blur, alt: 'Blur image' }} |
| 148 | + mainAttributes={{ ...image.hero, onClick: handleClick, alt: 'Hero image' }} |
| 149 | + className={cn('cursor-pointer my-0', className)} |
| 150 | + divClassName={divClassName} |
| 151 | + /> |
| 152 | + ); |
| 153 | +}; |
| 154 | + |
| 155 | +``` |
| 156 | + |
| 157 | +## Blur preloader |
| 158 | + |
| 159 | +This is the most interesting part of the feature. The first instinct when swapping the blur and main images might be to use a ternary operator to mount or unmount the appropriate image. But we actually can’t do that here. Why? Because both images need to remain mounted in the DOM to ensure the `onLoad` event works correctly for both the blur and main images. So instead of unmounting, we will use absolute positioning to place the main image above the blur image and toggle its opacity to show or hide it. |
| 160 | + |
| 161 | +But there is more. Note that with the `onLoad` event, we have three possible values for the image’s src attribute (although the main image actually uses the `srcset` and `sizes` attributes). These are: |
| 162 | + |
| 163 | +1. An empty string `''` when both blur and main images are still loading. In this case we will show an empty `<div />` of the same size as the main image. |
| 164 | +2. The `src` attribute of the blur image, when the blur image is loaded but the main image is still loading. |
| 165 | +3. The `srcset` and `sizes` attributes of the main image, once the main image has fully loaded. |
| 166 | + |
| 167 | +This is the code [src/components/react/ImageBlurPreloader.tsx](https://github.com/nemanjam/nemanjam.github.io/blob/c1e105847d8e7b4ab4aaffad3078726c37f67528/src/components/react/ImageBlurPreloader.tsx): |
| 168 | + |
| 169 | +```tsx |
| 170 | +// src/components/react/ImageBlurPreloader.tsx |
| 171 | + |
| 172 | +const initialAttributes: ImgTagAttributes = { src: '' } as const; |
| 173 | + |
| 174 | +const ImageBlurPreloader: FC<Props> = ({ |
| 175 | + blurAttributes = initialAttributes, |
| 176 | + mainAttributes = initialAttributes, |
| 177 | + onMainLoaded, |
| 178 | + className, |
| 179 | + divClassName, |
| 180 | +}) => { |
| 181 | + const [isLoadingMain, setIsLoadingMain] = useState(true); |
| 182 | + const [isLoadingBlur, setIsLoadingBlur] = useState(true); |
| 183 | + |
| 184 | + const prevMainAttributes = usePrevious(mainAttributes); |
| 185 | + |
| 186 | + const isNewImage = !( |
| 187 | + prevMainAttributes?.src === mainAttributes.src && |
| 188 | + prevMainAttributes.srcSet === mainAttributes.srcSet |
| 189 | + ); |
| 190 | + |
| 191 | + // reset isLoading on main image change |
| 192 | + useEffect(() => { |
| 193 | + if (isNewImage) { |
| 194 | + setIsLoadingBlur(true); |
| 195 | + setIsLoadingMain(true); |
| 196 | + } |
| 197 | + }, [isNewImage, setIsLoadingMain, setIsLoadingBlur]); |
| 198 | + |
| 199 | + // important: main image must be in DOM for onLoad to work |
| 200 | + // unmount and display: none; will fail |
| 201 | + const handleLoadMain = () => { |
| 202 | + setIsLoadingMain(false); |
| 203 | + onMainLoaded?.(); |
| 204 | + }; |
| 205 | + |
| 206 | + const commonAttributes = { |
| 207 | + // blur image must use size from main image |
| 208 | + width: mainAttributes.width, |
| 209 | + height: mainAttributes.height, |
| 210 | + }; |
| 211 | + |
| 212 | + const blurAlt = !isLoadingBlur ? blurAttributes.alt : ''; |
| 213 | + const mainAlt = !isLoadingMain ? mainAttributes.alt : ''; |
| 214 | + |
| 215 | + const hasImage = Boolean( |
| 216 | + isLoadingMain |
| 217 | + ? mainAttributes.src || mainAttributes.srcSet |
| 218 | + : blurAttributes.src || blurAttributes.srcSet |
| 219 | + ); |
| 220 | + |
| 221 | + return ( |
| 222 | + <div className={cn('relative size-full', divClassName)}> |
| 223 | + {hasImage && ( |
| 224 | + <> |
| 225 | + {/* blur image */} |
| 226 | + <img |
| 227 | + {...blurAttributes} |
| 228 | + {...commonAttributes} |
| 229 | + alt={blurAlt} |
| 230 | + onLoad={() => setIsLoadingBlur(false)} |
| 231 | + className={cn('object-cover absolute top-0 left-0 size-full', className)} |
| 232 | + /> |
| 233 | + |
| 234 | + {/* main image */} |
| 235 | + <img |
| 236 | + {...mainAttributes} |
| 237 | + {...commonAttributes} |
| 238 | + alt={mainAlt} |
| 239 | + onLoad={handleLoadMain} |
| 240 | + className={cn( |
| 241 | + 'object-cover absolute top-0 left-0 size-full', |
| 242 | + // important: don't hide main image until next blur image is loaded |
| 243 | + isLoadingMain && !isLoadingBlur ? 'opacity-0' : 'opacity-100', |
| 244 | + className |
| 245 | + )} |
| 246 | + /> |
| 247 | + </> |
| 248 | + )} |
| 249 | + </div> |
| 250 | + ); |
| 251 | +}; |
| 252 | +``` |
| 253 | + |
| 254 | +That is a lot of code, so let’s break it down. First, note the use of `relative` and `absolute` classes to position the images on top of each other. |
| 255 | + |
| 256 | +We set the initial `src` to an empty string in the `initialAttributes` variable. This sets the `hasImage` flag to `true`, unmounts the images, and displays an empty `<div>` that fills the parent container thanks to the `size-full` class (which is needed to prevent layout shift). |
| 257 | + |
| 258 | +Next, note that we track the separate states `isLoadingMain` and `isLoadingBlur` for the main and blur images. Both are necessary so we can correctly show/hide the main image by changing its opacity from `opacity-0` to `opacity-100`. The general idea is this: "Always keep the blur image below, just show or hide the main image above." |
| 259 | + |
| 260 | +Additionally, we track the previous main image, `prevMainAttributes`, to detect when a new image is selected via the `onClick` event passed from the parent component. |
| 261 | + |
| 262 | +Finally, while an image is loading, we set its `alt` attribute (using the `blurAlt` and `mainAlt` variables) to an empty string to avoid rendering text in place of an empty image, as it doesn't look nice. |
| 263 | + |
| 264 | +**Bonus tip:** You can also experiment with the `<img style={{imageRendering: 'pixelated'}} />` scaling style on the blur image if you find it more aesthetically pleasing. |
| 265 | + |
| 266 | +## Cumulative layout shift |
| 267 | + |
| 268 | +This is also an interesting part. In general, the server always sends images with their sizes (at least it should), which makes handling layout shifts easier, so we should be able to solve it properly. |
| 269 | + |
| 270 | +The key point is this: Set the component's actual size **in the server component** `ImageRandom.astro` and use `w-full h-full` (`size-full`) in the client `ImageRandom.tsx` React component to stretch it to fill the parent. This way, the size is resolved on the server, and there is no shift when hydrating the client component. |
| 271 | + |
| 272 | +Lets see it in practice [src/components/ImageRandom.astro#L21](https://github.com/nemanjam/nemanjam.github.io/blob/cb36b621ebae583dee693dd6ef6e6ece0028c468/src/components/ImageRandom.astro#L21) |
| 273 | + |
| 274 | +```tsx |
| 275 | +// src/components/ImageRandom.astro" |
| 276 | + |
| 277 | +--- |
| 278 | +// add 'px' suffix or styles will fail |
| 279 | +const { width, height } = Object.fromEntries( |
| 280 | + Object.entries(IMAGE_SIZES.FIXED.MDX_XL_16_9).map(([key, value]) => [key, `${value}px`]) |
| 281 | +); |
| 282 | +--- |
| 283 | + |
| 284 | +{/* height and width MUST be defined ON SERVER component to prevent layout shift */} |
| 285 | +{/* set height and width to image size but set real size with max-height and max-width */} |
| 286 | + |
| 287 | +<div |
| 288 | + class={cn('max-w-full max-h-64 md:max-h-96 my-8', className)} |
| 289 | + style={{ width, height }} |
| 290 | + {...props} |
| 291 | +> |
| 292 | + <ImageRandomReact {galleryImages} client:load /> |
| 293 | +</div> |
| 294 | +``` |
| 295 | + |
| 296 | +We use the `max-w-...` and `max-h-...` classes to set the actual (responsive) size for the server component, which the client component will fill. |
| 297 | + |
| 298 | +The `my-8` margin is there to override the vertical margin styles for the image component in the markdown (`prose` class). Remember, we have two actual, absolutely positioned `<img />` tags in the DOM, so `prose` will add double margins, and we need to correct that. |
| 299 | + |
| 300 | +Client component [src/components/react/ImageBlurPreloader.tsx](https://github.com/nemanjam/nemanjam.github.io/blob/c1e105847d8e7b4ab4aaffad3078726c37f67528/src/components/react/ImageBlurPreloader.tsx): |
| 301 | + |
| 302 | +```tsx |
| 303 | +// src/components/react/ImageBlurPreloader.tsx |
| 304 | + |
| 305 | +const ImageBlurPreloader: FC<Props> = ({ |
| 306 | + // ... |
| 307 | + className, |
| 308 | + divClassName, |
| 309 | +}) => { |
| 310 | + // ... |
| 311 | + |
| 312 | + return ( |
| 313 | + <div className={cn('relative size-full', divClassName)}> |
| 314 | + {hasImage && ( |
| 315 | + <> |
| 316 | + {/* blur image */} |
| 317 | + <img className={cn('object-cover absolute top-0 left-0 size-full', className)} /> |
| 318 | + |
| 319 | + {/* main image */} |
| 320 | + <img className={cn('object-cover absolute top-0 left-0 size-full')} /> |
| 321 | + </> |
| 322 | + )} |
| 323 | + </div> |
| 324 | + ); |
| 325 | +}; |
| 326 | +``` |
| 327 | + |
| 328 | +In the client component, we simply stretch all elements with `size-full` to fill the parent server component. |
| 329 | + |
| 330 | +With this in place, we achieve the following score for the cumulative layout shift: |
| 331 | + |
| 332 | + |
| 333 | + |
| 334 | +## Completed code and demo |
| 335 | + |
| 336 | +- **Demo:** https://nemanjamitic.com/ |
| 337 | +- **Github repository:** https://github.com/nemanjam/nemanjam.github.io |
| 338 | + |
| 339 | +The relevant files: |
| 340 | + |
| 341 | +```bash |
| 342 | +# https://github.com/nemanjam/nemanjam.github.io/tree/c1e105847d8e7b4ab4aaffad3078726c37f67528 |
| 343 | +git checkout c1e105847d8e7b4ab4aaffad3078726c37f67528 |
| 344 | + |
| 345 | +# random image code |
| 346 | +src/pages/index.mdx |
| 347 | +src/components/ImageRandom.astro |
| 348 | +src/components/react/ImageRandom.tsx |
| 349 | +src/components/react/ImageBlurPreloader.tsx |
| 350 | + |
| 351 | +# common code with gallery |
| 352 | +src/libs/gallery/images.ts |
| 353 | +src/libs/gallery/transform.ts |
| 354 | +src/constants/image.ts |
| 355 | +``` |
| 356 | + |
| 357 | +## Outro |
| 358 | + |
| 359 | +Once again, we played around with images, Astro, and React. Have you implemented any similar components yourself, maybe a carousel? What was your approach? Do you have suggestions for improvements or have you spotted anything incorrect? Don’t hesitate to leave a comment below. |
| 360 | + |
| 361 | +## References |
| 362 | + |
| 363 | +- React image preloader tutorial https://benhoneywill.com/progressive-image-loading-with-react-hooks/ |
| 364 | +- Astro documentation, tutorial how to use `getImage()` function https://docs.astro.build/en/recipes/build-custom-img-component/ |
| 365 | +- "Squared" image scaling algorithm styles https://www.w3schools.com/cssref/css3_pr_image-rendering.php |
0 commit comments