Skip to content

Commit d0d9e0c

Browse files
committed
random image article, add devto and hashnode markdown
1 parent 063a3b8 commit d0d9e0c

File tree

2 files changed

+729
-0
lines changed

2 files changed

+729
-0
lines changed
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
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+
![Lighthouse score layout shift](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v7vkqymd2q602pyai2gl.png)
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

Comments
 (0)