쌈@뽕한 이미지 Lazy Loading

애니메이션과 canvas를 곁들인

2024년 8월 11일3분

Awwwards에서 레퍼런스를 찾으면서 눈 호강을 하던 중 한 가지 재미있는 인터랙션을 발견했습니다. 매우 많은 SOTD(Site Of The Day)를 수상한 캐나다의 Locomotive라는 웹 에이전시 홈페이지의 이미지 전반에 적용된 애니메이션이었습니다.

Reference

마치 처음에는 이미지 로딩이 덜 된 것 같은 저화질의 픽셀 이미지에서 점점 고화질로 변화하는 애니메이션인데, 보는 사람 입장에서 재미도 있지만 Lazy Loading과 함께 적용하면 사용자 경험 측면에서도 제법 훌륭한 인터랙션이라고 생각했습니다.

 

구현에 앞서

접근 1

공개된 소스 코드나 레포지토리가 없어서 접근 방법에 대한 힌트를 얻고자 개발자 도구 창을 열고 커서를 이리저리 움직이며 이미지 주변의 DOM 변화를 관찰했습니다. 구체적으로는 알 수 없었지만, 대략적으로는 이미지가 불러와지기 전에 표시할 Blur 이미지를 배경으로 깔고 이미지가 불러와지면 canvas로 구현한 필터가 적용되었다가 사라지는 방식으로 구현한 것을 확인할 수 있었습니다.

접근 2

그리고 네트워크 탭에서 이미지가 불러와지는 시점 또한 확인해서 Lazy Loading이 적용되어있음을 알 수 있었습니다.

 

이렇게 알아낸 정보를 가지고, 방법을 구체화해 보았습니다.

  1. 원본 이미지가 불러와지기 전에 표시될 대체 이미지를 만든다.
  2. next/imageImage를 사용해 대체 이미지를 blurDataUrl로 넣고, Lazy Loading을 구현한다.
  3. 이미지를 canvas로 덮는다. (absolute)
  4. canvas에 Pixelation 필터 기능을 추가한다.
  5. useEffectsetInterval, 클린업 함수를 사용해 시간의 흐름에 따른 Pixel의 크기를 제어한다.

위의 방법대로 Next.js, TypeScript, Tailwind CSS를 사용해 Lazy Loading 애니메이션을 구현해보았습니다. 대체 이미지는 블로그에 사용된 Velite에서 이미지를 처리하면서 생성된 blurDataUrl을 사용했습니다. (Locomotive 웹사이트는 Vue 기반으로 작성되어있습니다.)

 

Lazy Loading 구현

loading="lazy"next/image의 기본값이기 때문에 특별히 작성해줘야 할 코드는 없습니다. Velite에서 생성한 blurDataUrlImage의 속성으로 추가해줍니다. next/imageblurDataUrl 속성은 placeholder="blur"가 함께 작성되어야 작동합니다.

1
return (
2
<div className="flex">
3
<div className="relative">
4
<Image
5
src="/image.jpg"
6
alt="image"
7
placeholder="blur"
8
blurDataURL="data:image/webp;base64,..."
9
width={640}
10
height={360}
11
/>
12
<canvas className="absolute left-0 top-0 h-full w-full" />
13
</div>
14
</div>
15
);

 

Pixelation 필터 구현

필터 구현을 위해 Imagecanvasref 속성으로 참조해줍니다.

1
'use client'
2
3
...
4
5
const canvas = useRef<HTMLCanvasElement>(null)
6
const image = useRef<HTMLImageElement>(null)
7
8
...
9
10
<Image
11
...
12
ref={image}
13
/>
14
<canvas ref={canvas} ... />

그리고 useEffect 내부에 이미지를 canvas로 불러와서 구간(pixelSize) 별로 사각형에 색을 추가하도록 Pixelation 필터를 구현해줍니다.

1
useEffect(() => {
2
if (canvas.current && image.current) {
3
const ctx = canvas.current.getContext("2d");
4
const img = image.current;
5
6
if (ctx) {
7
const width = canvas.current.width;
8
const height = canvas.current.height;
9
10
ctx.drawImage(img, 0, 0, width, height);
11
12
const pixelSize = 12;
13
14
const imageData = ctx.getImageData(0, 0, width, height);
15
const data = imageData.data;
16
17
for (let y = 0; y < height; y += pixelSize) {
18
for (let x = 0; x < width; x += pixelSize) {
19
const i = (y * width + x) * 4;
20
21
ctx.fillStyle = `rgb(${data[i]}, ${data[i + 1]}, ${data[i + 2]})`;
22
ctx.fillRect(x, y, pixelSize, pixelSize);
23
}
24
}
25
}
26
}
27
}, []);

여기에서 사용된 pixelSize 변수는 렌더링과 연관되어있는 값이기 때문에 최종적으로는 state로 분리되어야 합니다.

결과물을 확인해보면 Pixelation 필터가 잘 적용된 것을 확인해볼 수 있습니다.

중간 산출물

 

Pixel 크기 제어

시간에 따른 pixelSize를 제어하기 위해 위의 useEffect 코드에서 사용되었던 pixelSize를 상태로 분리해줍니다. 그리고 pixelSize 값이 변함에 따라 필터를 새로 적용해야 하므로 기존 useEffect의 의존성 배열에 pixelSize 상태를 추가해줍니다.

새로운 useEffect 내부에 setInterval과 클린업 함수를 사용해 pixelSize 상태값을 변경하는 코드를 작성해줍니다. 그리고 이 intervalpixelSize가 1보다 작아지면 중단되어야 합니다.

1
const [pixelSize, setPixelSize] = useState(128);
2
3
useEffect(() => {
4
const intervalId = setInterval(() => {
5
setPixelSize((prevSize) => {
6
const newSize = prevSize / 2;
7
if (newSize < 1) {
8
clearInterval(intervalId);
9
return 1;
10
}
11
return newSize;
12
});
13
}, 100);
14
15
return () => clearInterval(intervalId);
16
}, []);

 

결과

Result

전체 코드

1
"use client";
2
3
import { useEffect, useRef, useState } from "react";
4
import Image from "next/image";
5
6
function ImageLazyLoading() {
7
const canvas = useRef<HTMLCanvasElement>(null);
8
const image = useRef<HTMLImageElement>(null);
9
const [pixelSize, setPixelSize] = useState(128);
10
11
useEffect(() => {
12
const intervalId = setInterval(() => {
13
setPixelSize((prevSize) => {
14
const newSize = prevSize / 2;
15
if (newSize < 1) {
16
clearInterval(intervalId);
17
return 1;
18
}
19
return newSize;
20
});
21
}, 100);
22
23
return () => clearInterval(intervalId);
24
}, []);
25
26
useEffect(() => {
27
if (canvas.current && image.current) {
28
const ctx = canvas.current.getContext("2d");
29
const img = image.current;
30
31
if (ctx) {
32
const width = canvas.current.width;
33
const height = canvas.current.height;
34
35
ctx.drawImage(img, 0, 0, width, height);
36
37
const imageData = ctx.getImageData(0, 0, width, height);
38
const data = imageData.data;
39
40
for (let y = 0; y < height; y += pixelSize) {
41
for (let x = 0; x < width; x += pixelSize) {
42
const i = (y * width + x) * 4;
43
44
ctx.fillStyle = `rgb(${data[i]}, ${data[i + 1]}, ${data[i + 2]})`;
45
ctx.fillRect(x, y, pixelSize, pixelSize);
46
}
47
}
48
}
49
}
50
}, [pixelSize]);
51
52
return (
53
<div className="flex">
54
<div className="relative">
55
<Image
56
src="/image.jpg"
57
alt="image"
58
placeholder="blur"
59
blurDataURL="data:image/webp;base64,UklGRjIAAABXRUJQVlA4ICYAAACQAQCdASoIAAUADMDOJaQAAlw+c6AA/N1PdIpY7181Qu2DaIAAAA=="
60
width={640}
61
height={360}
62
ref={image}
63
/>
64
<canvas ref={canvas} className="absolute left-0 top-0 h-full w-full" />
65
</div>
66
</div>
67
);
68
}
69
70
export { ImageLazyLoading };

 


 

이렇게 canvas와 애니메이션을 활용해서 보다 부드럽게 연결되는 Lazy Loading을 구현해봤습니다. 결과물은 [공사중]에서도 확인해보실 수 있습니다!