애니메이티드 커서 적용하기
개발 이야기갑자기 문득 기행이 하고 싶어서, 웹 화면 로딩 중에 마우스 커서를 모래시계로 만들고싶어졌다.
하고 싶으면, 그냥 해야지.
착안은 위와 같은 구형 윈도우의 레트로한 모래시계였다. 인터넷 검색하면 잘 나온다.
이것을 원본으로 하여 구현을 해보기로.
우리는 보통 CSS에서 cursor를 사용할 때, 뭔가 버튼 아닌 것을 버튼 처럼 보이게 하기 위해 cursor: pointer
를 쓰곤 한다.
잠시 잡설 들어간다. 나는 이 부분을 매우 비추하는 편이다. 버튼은 button 태그로, 링크는 a 태그를 써야 한다. 만약 당신이 <div role="button" tabIndex={0} onClick={onClickSomething}>
이래놓고, CSS에는 cursor: pointer
라고 하고 있다면, 그냥 <button onClick={onClickSomething}>
으로 쓰면 될걸 삽질하고 있는 것이다. 또한 저 onClickSomething 내용물이 window.location.href = 어디론가
이러고 있다면 짤없이 <a href="어디론가">
라고 쓸 곳을 잘못 쓰고 있는 것이다.
하지만 cursor에는 숨겨진 비밀 기능이 있다. url()을 받아서 이미지를 보여줄 수 있는 것이다. MDN 문서를 참고하자. cursor: url(이미지경로), wait;
라고 적어주면 이미지로 커서를 대체할 수가 있다. 어.. 그리고 참고로 뒤에 wait
안붙여주면 정상 동작하지 않을 것이다. 이미지 커서가 어떠한 이유로 안나올 경우 대체값을 지정하는 부분인데, 그냥 내 경우 모래시계기 때문에 wait을 찍으면 된다. 모래시계 말고 평소의 커서를 갈고 싶다면 auto
박으면 된다.
이렇게만 하면 이미지로 커서 만드는건 정말 껌이다. *.cur 파일, *.png, *.svg 파일 등을 쓸 수 있다. 파이어폭스 기준으로 가로세로 128px 까지 지원되니까 대빵만한 이미지를 넣는건 지양하자. 여기서 문제는 애니메이션이다. *.ani 파일은 아예 나오지도 않을 것이고, 애니메이션 처리된 *.gif 파일은 크롬이나 파이어폭스에서 멈춰 보인다. 심지어 윈도우용 사파리(옛날거다)에서는 가로로 커서를 움직일 때만 애니메이션 되었다고 한다 ㅋㅋ;;
그렇다면 애니메이션 커서를 만드는 방법은 총 세가지가 있다.
- 이미지 여러장을 css animation을 이용해 교체한다.
- 애니메이티드 svg 파일을 사용한다.
- JS로 아예 커서 이미지가 마우스 포인터를 따라다니도록 한다.
여기서 3번의 경우 코드 작업이 많이 필요하고, 비주얼을 CSS에만 맏기는 내 철학에 맞지 않아 제외했다. 아 뭐 저렇게 만들면 원자폭탄 터지는 동영상도 마우스를 따라다닐 수 있겠지. 막 유튜브 동영상도 따라다니게 하고 어휴.. 그럼 2번이 제일 깔끔하긴 한데, 문제는 Figma 등의 툴에서 애니메이티드 SVG를 만들 방법이 없다는 것이다. 내가 룩딸 광이긴 하지만 AfterEffect를 다루지는 못하므로 이 부분도 어쩔 수 없이 패스했다. 시중에 SVG 애니메이션 제네레이터 웹 서비스가 몇개 있지만, 키프레임 단위로 움직이게 해야 하는 내 컨셉과는 맞지 않았다. 예들은 막 선을 늘이고 줄이고 돌리고 이런거에 최적화되어있더라구.
첫번째 시도 - Multiple Images
자 첫번째 시도. 좀 무식하지만 여러장의 이미지 파일을 만들어서 교체하도록 한다.
포토샵 열어서 점 겁나 찍는다. 참고로 레트로한 느낌을 주기 위해서는 각이 살아있어야 하는데, 1x1px 짜리 점은 너무 작아서 2x2px로 네배 확대하고 싶었다. 레티나!
확대/축소 할 때는 이렇게 Resample 옵션에서 Nearest Neighbor (hard edges)를 쓰면 스무딩 없이 각이 살아 있는 커서를 만들 수 있다. 다음과 같이 각 키프레임용 파일들이 준비되었다.
와우 현기증나.. 다행히 원본 이미지 소스가 괜찮아서 작업하는데 어렵지는 않았다. 포토샵에서는 Save As Web으로 한 다음, PNG-8을 선택하고, colors를 8개 정도로 줄여서 색상 리밋을 걸어주자. 이렇게 하면 GIF 형식보다 용량 적은 투명 png 파일을 만들 수 있다.
이제 CSS 차례.
.loadingPage {
@include m.flexbox;
width: 100%;
min-height: 100vh;
animation: hourGlassCursor 1s infinite;
&.inComponent {
min-height: initial;
}
}
@keyframes hourGlassCursor {
0% {
cursor: url('/images/hourGlassCursor/cursor1.png'), auto;
}
6% {
cursor: url('/images/hourGlassCursor/cursor2.png'), auto;
}
12% {
cursor: url('/images/hourGlassCursor/cursor3.png'), auto;
}
18% {
cursor: url('/images/hourGlassCursor/cursor4.png'), auto;
}
24% {
cursor: url('/images/hourGlassCursor/cursor5.png'), auto;
}
30% {
cursor: url('/images/hourGlassCursor/cursor6.png'), auto;
}
36% {
cursor: url('/images/hourGlassCursor/cursor7.png'), auto;
}
42% {
cursor: url('/images/hourGlassCursor/cursor8.png'), auto;
}
48% {
cursor: url('/images/hourGlassCursor/cursor9.png'), auto;
}
54% {
cursor: url('/images/hourGlassCursor/cursor10.png'), auto;
}
60% {
cursor: url('/images/hourGlassCursor/cursor11.png'), auto;
}
66% {
cursor: url('/images/hourGlassCursor/cursor12.png'), auto;
}
72% {
cursor: url('/images/hourGlassCursor/cursor13.png'), auto;
}
78% {
cursor: url('/images/hourGlassCursor/cursor14.png'), auto;
}
84% {
cursor: url('/images/hourGlassCursor/cursor15.png'), auto;
}
100% {
cursor: url('/images/hourGlassCursor/cursor1.png'), auto;
}
}
아이고 무식해라 ㅋㅋㅋㅋ 총 15장이므로 대충 6%씩 이동하도록 만들었다.
적용을 해보니 다음과 같이 매우 잘 된다.
네트워크와 브라우저 부하
근데 이 방식의 문제점은 여러 이미지 파일을 로딩해야 한다는 점이다. 비록 1kb짜리 파일이지만 총 15개의 요청이 필요하다.
HTTP1.1 기준으로 크롬, 파이어폭스, 사파리, 오페라 등은 도메인 하나당 한번에 6개의 병렬 다운로드를 지원한다. 그리고 앞의 6개 중 뭐라도 먼저 끝나면 다음걸 시작해서 6개를 채운다. IE7은 2개, IE8,9는 6개, IE10은 8개, IE11은 13개지만 요즘 같은 시대에는 어울리지 않는 갯수이다. 청크 파일이며 이미지 파일이 얼마나 많은데..
HTTP1.1은 솔직히 관짝 들어가야 하고, HTTP/2나 3에선 어떨까? 기본적으로 HTTP/2에서는 도메인당 1개의 연결만 허용하고, 이 안에서 멀티플렉싱으로 여러개를 다운로드 받게 지원하고 있다. 따라서 일단은 무제한이라 볼 수 있다.
https://web.dev/performance-http2/ 에서 가져온 짤이다.
진짜 무제한일까? 그럼 테스트 해봐야지.
이미지 폴더를 복붙해서 총 150개의 커서 파일을 동시에 다운로드 하도록 해봤다. 좀 미친짓이긴 한데.. 위는 내 로컬 환경에서 테스트한거라 HTTP/1.1로 동작한다. 위에서 설명한대로 딱 6개씩 짝지어서 다운받고, 또 다운받고 하는 규칙성을 볼 수 있다. 또한 굉장히 심하게 버벅거리는 모습을 관찰할 수 있었다.
참고로 내 컴 똥컴 아니다. 모니터 세개 달고 쓰는 M1 Pro Max CTO 버전이다. 자 그럼 서버에 올려서 HTTP/3로 동작하면 어떻게 나올까? 두근두근..
앞에 6개 까지는 정상적인 스피드로 다운로드 받고, 뒤에것들은 한참 오래걸리지만 아무튼 병렬로 다운로드하는 것을 볼 수 있다. 아마 서버가 힘들어하는 상황이 아니려나.. 스펙을 올려야하나.. 클릭해서 상세를 보니까 서버 리스폰스 기다리는데 500밀리초가 걸렸다. 실제 다운로드는 5밀리초 컷 ㅋㅋ
그렇다면 이미지 처리에 최적화된 CDN에 올리면 어떻게 될까? 파일을 DigitalOcean Spaces에 올리고 ImageKit 서버를 통해 다운로드 받아보도록 하자.
역시 갓 CDN.. 서버 리스폰스가 20밀리초에 다운로드 받는데 20밀리초 나왔다. 변태적인 요청임에도 불구하고 서버 리스폰스 자체가 무지하게 빠르다. 대신 이번에도 역시 초기 로딩시 브라우저가 얼어붙는 현상이 발생했다.
아무튼 뭐 저거 처럼 파일 갯수 150개 다운로드 받을것도 아니고 노상관 아냐? 할 수 있는데.. 형, 일단 지저분하잖아… 방문자가 와서 내 네트워크 탭을 열어보면 웃을거 아니냐고.. 저 커서 png 파일 모래시계 하나하나 다 보인단 말야…
뭐냐고 이게 ㅋㅋ 매우 쪽팔리는 상황인거지. 일단 돌아가는 모래시계 하나 구현한다고 파일 15장 쓰는건 용납이 안돼!
두번째 시도 - Animated SVG
기껏 포토샵을 이용해 비트맵을 찍었지만, 피그마를 이용해서 아래와 같이 벡터로 옮겨주었다. 뻘짓 좋아..
좀 단순 노동 느낌이라 재미있다. 가끔 이렇게 머리를 식혀줘야 바보가 되지 않는다.
아 가끔 초짜 디자이너들이 비트맵 이미지를 Figma에 붙여넣고 SVG로 익스포트 하는 경우가 있다. 이건 사실상 무늬만 SVG지 코드 열어보면 속은 비트맵인 무쓸모한 파일이다. 벡터의 장점을 느끼기 위해 쓰는게 SVG니까, 비트맵은 JPG/PNG 등의 파일을 사용하자.
음.. 코드를 최소화 하기 위해 상자 갯수는 가능한한 줄였다. 위에 한줄을 싹 그룹으로 묶은 다음, 언그룹을 끝까지 해줘서 평평하게 펴고 다시 그룹으로 감싸준다. 언그룹 안하면 작업할 때 썼던 쓸데없는 레이어들이 포함되기 때문이다. 다 했으면 export~
아래와 같이 SVG 파일이 나온다.
<svg width="750" height="50" viewBox="0 0 750 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="42" width="26" height="4" fill="black"/>
<rect x="12" y="2" width="24" height="4" fill="#008180"/>
<rect x="14" y="40" width="20" height="2" fill="#008180"/>
<rect x="12" y="42" width="24" height="2" fill="#008180"/>
<rect x="16" y="4" width="8" height="2" fill="#03FFFF"/>
... 으아아아 길다
</svg>
용량이 50kb 정도인데, 하긴 상자가 몇백개니.. 이건 좀 큰 감이 있다.
svgo로 minify 해준다. 내 경우 VSCode 플러그인을 사용했다.
<svg width="750" height="50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="#000" d="M12 42h26v4H12z"/>
<path fill="#008180" d="M12 2h24v4H12zM14 40h20v2H14zM12 42h24v2H12z"/>
<path fill="#03FFFF" d="M16 4h8v2h-8z"/>
<path fill="#000" d="M36 2h2v4h-2zM34 8h2v8h-2zM34 32h2v10h-2z"/>
<path fill="silver" d="M16 8h18v8H16zM16 32h18v8H16z"/>
... 으아아 길다
</svg>
위는 내가 보기 편하게 해줄라고 줄바꿈을 했는데, 줄바꿈 없이 원래 한줄로 다 붙어나온다. 16kb로 확 줄어들었다. 이 정도 되면 png 파일 15장 (다 합쳐서 17kb) 보다는 용량이 줄었다. 실제로 드는 네트워크 대역폭을 생각해보면 훨씬 이득이다. 이건 원큐에 다운로드 받을 수 있으니까 훨씬 보기 좋다.
이제 애니메이션 효과를 줄 때이다.
<svg width="25" height="25" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg">
여기 가로 세로를 25씩 잡아서 25x25px로 나오게 만들고, 뷰박스를 0 0 50 50으로 해주었다. 이러면 50x50 사이즈의 커서가 1/2로 줄어들어 나오게 된다. 이제 이 화면을 좌로 50px씩 옆으로 이동시키면서 순차적으로 보여주면 애니메이션이 만들어진다.
<g id="hourGlassCursor">
그리고 이렇게 그룹으로 path들을 전부 묶어준다. 그리고 id를 지정.
<svg width="25" height="25" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg">
<g id="hourGlassCursor">
<path>
...
</g>
<animateTransform
href="#hourGlassCursor"
attributeName="transform"
attributeType="XML"
type="translate"
calcMode="discrete"
values="0;-50;-100;-150;-200;-250;-300;-350;-400;-450;-500;-550;-600;-650;-700"
dur="1s"
repeatCount="indefinite"
/>
</svg>
대략 이런 느낌으로 맨 아래에 animateTransform
을 넣어준다. 이렇게 하면 hourGlassCursor
아이디를 가지고 있는 그룹을 transform: translate
로 0, -50, -100 … -700 까지 이동시켜준다. 여기서 calcMode
를 discrete
로 넣었는데, 이렇게 하면 마치 CSS Animation의 step
과 비슷하게 동작한다. 저거 안넣으면 리니어하게 움직이므로 아주 괴상해보인다.
다음으론 역시 CSS.. 원하는 곳에 다음의 코드만 넣으면 끝난다.
cursor: url('/svgs/hourGlassCursor.svg'), auto;
자 완성된걸 구경해볼까?
이건 또 뭔데 시벌레이션;; 움직임이 좀 이상하다. 마우스를 반드시 움직여야 애니메이션이 돌아간다. 마우스가 멈춰 있으면 애니메이션이 안바뀐다 ㅠㅠ 위에서 언급했던 윈도우용 사파리가 마우스를 가로로 움직여야만 돌아간다는 부분이랑 비슷한 현상이다. 킹받네…
브라우저 제조사가 커서 튜닝에 별 관심이 있을것 같지도 않고, 이 문제는 아마 평생 가도 해결이 되지 않을 수 있다. 에휴…….
세번째 시도 - JS
ㅋㅋㅋ 형 결국 여기까지 왔어.. 이게 답인가봐. 그토록 널 건드리지 않으려 했지만 이렇게 되었어……… ㅠㅠ 뭐 긍정적으로 생각하자구.. 일단 애니메이티드 SVG 까지 만들었으니까 번거롭게 이미지 교체하거나, 아니면 뭐 이미지 스프라이트 안만들어도 되니 다행이라 치자.
그럼 코드 들어간다.
const LoadingPage = ({ inComponent, test }: Props) => {
const hourGlassCursorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onMouseMove = (e: MouseEvent): void => {
const cursor = hourGlassCursorRef.current;
if (!cursor || !e.target) return;
if (e.target instanceof Element) {
const { localName } = e.target;
if (!localName) return;
if (['a', 'button'].includes(localName)) {
cursor.style.display = 'none';
} else {
cursor.style.display = 'block';
cursor.style.left = `${e.pageX - 25 / 2}px`;
cursor.style.top = `${e.pageY - 25 / 2}px`;
}
}
};
window.addEventListener('mousemove', onMouseMove);
return () => {
window.removeEventListener('mousemove', onMouseMove);
};
}, []);
return (
<div className={cx(styles.loadingPage, { [styles.inComponent]: inComponent, [styles.test]: test })}>
<Loading />
<div ref={hourGlassCursorRef} className={styles.hourGlassCursor} />
</div>
);
};
export default LoadingPage;
hourGlassCursorRef
먹여서 빈 div 하나를 만들어준다. 우리는 리액트 코드를 짜고 있으니까 getElementsById
쓰지 말고 ref를 쓰도록 하자. 껍데기는 레트로지만 알맹이는 모던해야지. 여기에 애니메이티드 SVG 커서가 들어갈 것이다. 그리고 페이지가 마운트 될 때 이벤트 리스너를 하나 붙여준다. 마우스가 움직일 때 마다 마우스 좌표값을 따다가 커서의 위치를 업데이트 해주는 식이다. 좌표값에 -25를 둘로 나눈걸 넣은 이유는, 대충 짐작하겠지만 마우스 중앙을 따라 움직이게 하고 싶어서이다.
중간에 e.target을 잡고 하는 부분은 혹시나 마우스 커서가 움직이다가 button이나 a 요소를 만날 경우를 대비해서 넣은 것이다. 저 코드가 없으면 버튼 위에 올려도 모래시계가 계속 돌아간다.. 클릭 가능한 요소를 만나면 display: none
을 먹이도록 했다.
잘 보면 어려울게 전혀 없는 코드다. 뭐 마우스 움직일 때 마다 계속 계산해야 할 코드지만 굳이 스로틀링 같은건 붙이지 않았다.
그리고 CSS는 다음과 같다.
.loadingPage {
cursor: none;
.hourGlassCursor {
position: absolute;
width: 25px;
height: 25px;
pointer-events: none;
background-image: url('/svgs/hourGlassCursor.svg');
}
}
백그라운드 이미지 깔아줬고, 가로세로 사이즈 잡은 다음, top과 left로 움직이게 하기 위해 absolute를 박았다. 그리고 상위 요소에 cursor: none
을 박아서 기본 마우스 커서가 겹쳐 보이지 않게 만들었다. 또한 pointer-events: none;
을 넣어서 모래시계 요소가 클릭을 무시하도록 만들었다. 혹시 모를 불상사를 막기 위함이다.
끝. 진짜 끝.
와 씨 엄청 잘 되네. 진작에 이렇게 할걸(?)
오늘의 결론. 마우스 커서를 이미지로 바꾸는 부분은 무난하게 할 수 있으나, 애니메이션을 건다면 상황이 달라진다. 브라우저별로 지원 여부가 불확실하고, 이상하게 동작하는 부분도 발견할 수 있었다. 커서 애니메이션을 걸고 싶다면 JS를 씁시다. 끝.
나는 친절하니까 관련 코드와 격리된 예제도 만들어놨으니 많관부.