어느 날, 카카오톡 채팅방에 눈이 내리기 시작했습니다. 바깥에 눈이 온다고 채팅방에도 눈을 내려주는 것이었죠. 사무실에서 일을 하며 밖을 내다보지 않아도 잠깐 본 카카오톡 채팅방이 밖에는 눈이 내리고 있다는 것을 일러주었습니다. 처음 이 애니메이션을 보았을 때 ‘참 귀엽다’고 생각하며 언젠가는 저도 서비스에 녹여내고 싶었습니다. 이 글은 이번 겨울을 준비하며 React, TypeScript, SCSS를 사용해 눈송이 내리는 애니메이션(이하 눈송이 효과)를 개발한 작업기입니다.
실제 블로그에 적용해서 동작하는 모습. 눈송이 효과를 On, Off하는 동작까지 보여주고 있다.
1 import * as React from "react";
2
3 export default function IconSnow() {
4 return (
5 <svg
6 xmlns="http://www.w3.org/2000/svg"
7 width={24}
8 height={24}
9 viewBox="0 0 512 512"
10 >
11 <path
12 fill="currentColor"
13 d="m461 349l-34-19.64a89.53 89.53 0 0 1 20.94-16a22 22 0 0 0-21.28-38.51a133.62 133.62 0 0 0-38.55 32.1L300 256l88.09-50.86a133.46 133.46 0 0 0 38.55 32.1a22 22 0 1 0 21.28-38.51a89.74 89.74 0 0 1-20.94-16l34-19.64A22 22 0 1 0 439 125l-34 19.63a89.74 89.74 0 0 1-3.42-26.15A22 22 0 0 0 380 96h-.41a22 22 0 0 0-22 21.59a133.61 133.61 0 0 0 8.5 49.41L278 217.89V116.18a133.5 133.5 0 0 0 47.07-17.33a22 22 0 0 0-22.71-37.69A89.56 89.56 0 0 1 278 71.27V38a22 22 0 0 0-44 0v33.27a89.56 89.56 0 0 1-24.36-10.11a22 22 0 1 0-22.71 37.69A133.5 133.5 0 0 0 234 116.18v101.71L145.91 167a133.61 133.61 0 0 0 8.52-49.43a22 22 0 0 0-22-21.59H132a22 22 0 0 0-21.59 22.41a89.74 89.74 0 0 1-3.41 26.19L73 125a22 22 0 1 0-22 38.1l34 19.64a89.74 89.74 0 0 1-20.94 16a22 22 0 1 0 21.28 38.51a133.62 133.62 0 0 0 38.55-32.1L212 256l-88.09 50.86a133.62 133.62 0 0 0-38.55-32.1a22 22 0 1 0-21.28 38.51a89.74 89.74 0 0 1 20.94 16L51 349a22 22 0 1 0 22 38.1l34-19.63a89.74 89.74 0 0 1 3.42 26.15A22 22 0 0 0 132 416h.41a22 22 0 0 0 22-21.59a133.61 133.61 0 0 0-8.5-49.41L234 294.11v101.71a133.5 133.5 0 0 0-47.07 17.33a22 22 0 1 0 22.71 37.69A89.56 89.56 0 0 1 234 440.73V474a22 22 0 0 0 44 0v-33.27a89.56 89.56 0 0 1 24.36 10.11a22 22 0 0 0 22.71-37.69A133.5 133.5 0 0 0 278 395.82V294.11L366.09 345a133.61 133.61 0 0 0-8.52 49.43a22 22 0 0 0 22 21.59h.43a22 22 0 0 0 21.59-22.41a89.74 89.74 0 0 1 3.41-26.19l34 19.63A22 22 0 1 0 461 349"
14 />
15 </svg>
16 );
17 }
1 import React, { useState, useEffect } from "react";
2 import "./Snowflakes.scss";
3 import IconSnow from "../icons/IconSnow";
4
5 interface SnowflakesProps {
6 count?: number; // 눈송이 개수
7 }
8
9 export default function Snowflakes({ count = 17 }: SnowflakesProps) {
10 const [snowflake, setSnowflake] = useState([]);
11
12 // 클라이언트 사이드에서만 실행되도록.
13 useEffect(() => {
14 const newSnowflake = Array.from({ length: count });
15 setSnowflake(newSnowflake);
16 }, []);
17
18 return (
19 <div
20 className={`snowflake ${snowflake.length ? "visible" : "hidden"}`}
21 aria-hidden="true"
22 >
23 {snowflake.map((flake, index) => (
24 <div key={`flake-${index}`} className="snowflake">
25 <IconSnow />
26 </div>
27 ))}
28 </div>
29 );
30 }
사이트에 내리는 눈송이들을 정의하는 컴포넌트입니다. 전달받은 숫자만큼 자식 컴포넌트를 렌더링 하는 코드로 크게 어려운 동작은 없습니다.
클라이언트 사이드에서만 실행되도록
useEffect
를 활용했습니다.
count
매개변수로 눈송이 개수를 설정할 수 있도록 합니다.
배열을 순회하며 눈송이 아이콘을 렌더링합니다.
만약 눈송이 별로 리렌더링 되는 상황이 생긴다면, 눈송이를 별도의 컴포넌트로 분리해야합니다.
응용하여 눈송이가 아닌 낙엽, 텍스트 등으로 대치 할 수 있습니다.
aria-hidden=”true”
를 설정하여 스크린리더에 감지되지 않도록 합니다.
1 import { generateRandomNumber } from '@utils/math';
2 ...
3
4 interface Snowflakes {
5 left: number;
6 fallDelay: number;
7 shakeDelay: number;
8 blur: number;
9 opacity: number;
10 size: number;
11 }
12
13 ...
14
15 export default function Snowflakes({ count = 17 }: SnowflakesProps) {
16
17 ...
18
19 // 클라이언트 사이드에서만 실행 되도록.
20 useEffect(() => {
21 const newSnowflake = Array.from({ length: count }).map(() => {
22 const fallDelay = generateRandomNumber(0, 15, { fixed: 2 });
23 const shakeDelay = Math.min(
24 generateRandomNumber(0, 10, { fixed: 1 }),
25 Number.parseFloat((fallDelay - 0.07).toFixed(1))
26 ); // fallDelay보다 무조건 길어야 한다. 그렇지 않으면 일부 구간 눈송이가 일자로 내리게 된다.
27 return {
28 left: generateRandomNumber(0, 100),
29 fallDelay,
30 shakeDelay,
31 blur: generateRandomNumber(0.2, 0.5, { fixed: 1 }),
32 opacity: generateRandomNumber(0.55, 0.95, { fixed: 2 }),
33 size: generateRandomNumber(12, 18),
34 };
35 });
36 setSnowflake(newSnowflake);
37 }, []);
38
39 return (
40 <div className={`snowflake ${snowflake.length ? 'visible' : 'hidden'}`} aria-hidden="true">
41 {snowflake.map((flake, index) => (
42 <div
43 key={`flake-${index}`}
44 className="snowflake"
45 style={{
46 left: `${flake.left}%`,
47 filter: `blur(${flake.blur}px)`,
48 opacity: `${flake.opacity}`,
49 animationDelay: `${flake.fallDelay}s, ${flake.shakeDelay}s`,
50 WebkitAnimationDelay: `${flake.fallDelay}s, ${flake.shakeDelay}s`,
51 }}
52 >
53 <IconSnow size={flake.size} />
54 </div>
55 ))}
56 </div>
57 );
58 }
1 import * as React from "react";
2
3 interface IconSnowProps {
4 size: number;
5 }
6 export default function IconSnow({ size }: IconSnowProps) {
7 return (
8 <svg
9 xmlns="http://www.w3.org/2000/svg"
10 width={size}
11 height={size}
12 viewBox="0 0 512 512"
13 >
14 <path
15 fill="currentColor"
16 d="m461 349l-34-19.64a89.53 89.53 0 0 1 20.94-16a22 22 0 0 0-21.28-38.51a133.62 133.62 0 0 0-38.55 32.1L300 256l88.09-50.86a133.46 133.46 0 0 0 38.55 32.1a22 22 0 1 0 21.28-38.51a89.74 89.74 0 0 1-20.94-16l34-19.64A22 22 0 1 0 439 125l-34 19.63a89.74 89.74 0 0 1-3.42-26.15A22 22 0 0 0 380 96h-.41a22 22 0 0 0-22 21.59a133.61 133.61 0 0 0 8.5 49.41L278 217.89V116.18a133.5 133.5 0 0 0 47.07-17.33a22 22 0 0 0-22.71-37.69A89.56 89.56 0 0 1 278 71.27V38a22 22 0 0 0-44 0v33.27a89.56 89.56 0 0 1-24.36-10.11a22 22 0 1 0-22.71 37.69A133.5 133.5 0 0 0 234 116.18v101.71L145.91 167a133.61 133.61 0 0 0 8.52-49.43a22 22 0 0 0-22-21.59H132a22 22 0 0 0-21.59 22.41a89.74 89.74 0 0 1-3.41 26.19L73 125a22 22 0 1 0-22 38.1l34 19.64a89.74 89.74 0 0 1-20.94 16a22 22 0 1 0 21.28 38.51a133.62 133.62 0 0 0 38.55-32.1L212 256l-88.09 50.86a133.62 133.62 0 0 0-38.55-32.1a22 22 0 1 0-21.28 38.51a89.74 89.74 0 0 1 20.94 16L51 349a22 22 0 1 0 22 38.1l34-19.63a89.74 89.74 0 0 1 3.42 26.15A22 22 0 0 0 132 416h.41a22 22 0 0 0 22-21.59a133.61 133.61 0 0 0-8.5-49.41L234 294.11v101.71a133.5 133.5 0 0 0-47.07 17.33a22 22 0 1 0 22.71 37.69A89.56 89.56 0 0 1 234 440.73V474a22 22 0 0 0 44 0v-33.27a89.56 89.56 0 0 1 24.36 10.11a22 22 0 0 0 22.71-37.69A133.5 133.5 0 0 0 278 395.82V294.11L366.09 345a133.61 133.61 0 0 0-8.52 49.43a22 22 0 0 0 22 21.59h.43a22 22 0 0 0 21.59-22.41a89.74 89.74 0 0 1 3.41-26.19l34 19.63A22 22 0 1 0 461 349"
17 />
18 </svg>
19 );
20 }
generateRandomNumber
함수 코드 보기
1 const generateRandomInt = (min = 0, max = 0) => {
2 if (min > max) {
3 return max;
4 }
5 return Math.floor(Math.random() * (max - min + 1)) + min;
6 };
7
8 const generateRandomFloat = (min = 0.0, max = 1.0, fixed = 1) => {
9 if (min > max) {
10 return max;
11 }
12 return parseFloat((Math.random() * (max - min) + min).toFixed(fixed));
13 };
14
15 interface RandomNumberOptionProps {
16 fixed?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
17 }
18 /**
19 *
20 * @param min 최소값 (이상)
21 * @param max 최대값 (이하)
22 * @param option fixed : 생성할 소수점 자리수
23 * @returns 랜덤수
24 */
25 export const generateRandomNumber = (
26 min: number,
27 max: number,
28 option?: RandomNumberOptionProps
29 ): number => {
30 if (min > max) {
31 return max;
32 }
33 if (option) {
34 const { fixed } = option;
35 if (fixed) {
36 return generateRandomFloat(min, max, fixed);
37 }
38 }
39 return generateRandomInt(min, max);
40 };
눈송이마다 랜덤한 스타일을 지정해줄 수 있도록 코드를 추가했습니다. 랜덤한 숫자가 많이 필요하므로 사용하기 편하게 별도의
generateRandomNumber
함수를 생성해서 사용했습니다.
다음과 같은 스타일을 랜덤으로 지정하여 인라인 스타일로 설정합니다.
fallDelay
: 내리기 시작하는 지연시간.
shakeDelay
: 좌우로 흔들리기 시작하는 지연시간.
fallDelay
보다 길 경우 눈송이가 일자로 떨어지는 구간이 생기니 주의.
IconSnow.tsx
에
size
프로퍼티를 추가해 다른 크기를 전달받을 수 있도록 했습니다.
1 @keyframes snowflake-fall {
2 0% {
3 top: -10%;
4 }
5 100% {
6 top: 100%;
7 }
8 }
9
10 @keyframes snowflake-shake {
11 0%,
12 100% {
13 transform: translateX(0px);
14 }
15 50% {
16 transform: translateX(80px);
17 }
18 }
19
20 .snowflake {
21 position: fixed;
22 left: 0;
23 top: 0;
24
25 .snowflake {
26 pointer-events: none; /* 다른 클릭이나 인터랙션을 방해하지 않도록 */
27 color: #fff;
28 text-shadow: 0 0 1px #000;
29 position: fixed;
30 top: -10%;
31 z-index: 0;
32 user-select: none;
33 cursor: default;
34 animation: snowflake-fall 10s linear infinite running,
35 snowflake-shake 3s ease-in-out infinite running;
36 }
37 }
38
애니메이션을 비롯해 사용자 선택에서 제외되도록 하는 등 눈송이에 공통적으로 설정할 스타일을 설정합니다.
공통으로 사용할 fall, shake 동작에 대한 애니메이션을 정의합니다.
snowflakes-fall
:
transform
을 이용해 눈송이가 수직으로 떨어지는 애니메이션.
snowflakes-shake
:
transform
을 이용해 눈송이가 좌우로 흔들리는 애니메이션.
애니메이션 동작에 대한 세부 내용을 정의합니다.
pointer-events: none;
을 설정해 다른 요소 클릭에 방해가 되지 않도록합니다.
user-select: none;
을 설정해 텍스트 선택이 되지 않도록합니다.
개인 블로그에 적용할 때에는 눈 내리는 애니메이션이 사용자한테 방해가 될 수도 있으므로 제어할 수 있는 권한을 넘겨주도록 했습니다. 홈 화면에서만 사용하기 때문에 로컬스토리지까지 사용하는 것은 좀 과한 것 같아 내부 상태 관리 라이브러리를 통해서만 관리하도록 했으며 홈 화면의 FAB를 통해 제어할 수 있도록 했습니다.
1 ...
2 import { useSnowflakeStore } from '@store/configStore';
3
4 export default function Snowflakes({ count = 17 }: SnowflakesProps) {
5 const { isShow } = useSnowflakeStore();
6
7 ...
8
9 return (
10 <div className={`snowflake ${isShow && snowflake.length ? 'visible' : 'hidden'}`} aria-hidden="true">
11 {snowflake.map((flake, index) => (
12 ...
13 ))}
14 </div>
15 );
16 }
1 .hidden {
2 visibility: hidden;
3 }
1 interface SnowflakeState {
2 isShow: boolean;
3 change: () => void;
4 }
5 export const useSnowflakeStore = create<SnowflakeState>(set => ({
6 isShow: true,
7 change: () => set(({ isShow }) => ({ isShow: !isShow })),
8 }));
상태 관리 라이브러리 Zustand를 사용해 눈송이 효과를 On/Off 할 수 있는 상태를 추가했으며, Boolean 상태에 따라
className
을 변경해주는 간단한 로직입니다.
개인적으로는 처음으로 Zustand를 사용해서 생성한 State였습니다.
눈송이를 과도하게 만들 경우에는 웹 사이트의 성능이 떨어지고, 랜덤 범위 내 눈송이의 밀도가 높아져 서로 다르게 내리는 것처럼 보이는 효과도 줄어들 수 있으니 사용자 경험을 해치지 않는 선에서 잔잔한 정도의 눈송이가 내리도록 하는 것이 좋습니다.
구글같은 경우에는 내리는 눈송이가 바닥에 쌓이고, 커서가 눈을 치울 수 있도록 하는 기능도 있었던 것이 기억에 납니다. 내리는 것보다 더 특별함을 찾는다면, 한 번 도전해봐도 재미있을 것 같습니다.
그 외에도 내부 아이콘을 변경해서 다양한 아이콘이 흩날리게 할 수도 있기 때문에 봄철에는 벚꽃 아이콘을 흩날리게 하는 것도 재미있을 것 같다고 생각했습니다. 어릴 적 재미있게 본 만화 <블리치>가 생각납니다.
때로는 기능과 의미, 깊이에의 강요에서 벗어나 단순하게 재미를 따라가 보는 것도 좋은 것 같습니다.