마우스 따라 돌아가는 눈알 만들기
개발 이야기로그인 창을 구현하다가, 비밀번호 가리기/보이기 버튼을 만들게 되었다.
근데 그냥 만들면 왠지 심심하니 좀 더 기믹을 넣어보고 싶었다. 아무래도 내 개인 프로젝트인 만큼 내 맘대로 하는 재미가 좀 있어야지. 다른 웹 사이트를 돌아다니다가 가끔 보면 마우스 움직임에 따라 다르게 반응하는 UI를 본 적이 있었다. 지금 샘플을 갖고 오라면 못찾겠는데.. 여튼, 내 마우스가 움직이는대로 눈알이 따라다니면 좀 섬뜩하면서도 재미있겠지.. 하는 생각이 들더라.
위에 나온 것 처럼 클릭 가능한 눈 버튼은 아주 흔한 UI다. 이제 저 눈을 마우스 움직임을 따라다니도록 만들어보자.
- 자유롭게 움직이는 눈알, 그리고 눈 테두리(?), 감은 눈 파일이 필요하다.
- 찾은 요소들을 적당히 마크업해서 배열한다.
- 마우스 포인터의 좌표값을 딴다.
- 좌표값으로 각도를 구한다.
- 눈알을 움직인다.
1번 - 눈알, 눈테두리, 감은눈 파일 찾기
가장 어려웠던 부분이다. 눈알과 눈 테두리, 그리고 감은 눈 파일이 필요한데 어울리는걸 구글링하기 힘들었다. 디자이너 단톡방 같은데 보내서 눈 테두리 부분에서 내용물만 쏙 빼달라고 요청했다. Figma에서 어째저째 만지면 될 것 같긴 한데 당최 모르겠더라...
이렇게 각각의 svg 파일을 마련한다. 디자이너분에게 부탁한 눈 테두리 파일은 사이즈가 좀 다르지만 나중에 조절하면 되니 신경쓰지 말자. 눈 테두리 위에 눈알을 올리고, 이제 눈알의 좌표만 움직여주면 된다.
2번 - 요소들 배열
const onClick = () => {
setShowPassword((prev) => !prev);
};
return (
<button type='button' className={styles.fancyEyeBall} onClick={onClick} aria-label='show password'>
{showPassword ? (
<>
<IconEyeTemplate className={styles.eyeTemplate} />
<IconEyeBall className={styles.eyeBall} />
</>
) : (
<IconEyeClosed className={styles.eyeClosed} />
)}
</button>
);
코드를 보면 버튼을 클릭하면 showPassword를 true/false로 토글해주는 부분이 보이고, showPassword일 때는 눈알과 눈알 테두리를 보여주도록, 그리고 !showPassword일 경우 감은 눈이 보이도록 간단하게 되어있다. 눈알과 눈알 테두리는 position: absolute 같은걸로 잘 정렬해주자. 버튼 자체에 버튼을 나타내는 아무런 힌트가 없기 때문에, a11y를 위해 aria-label을 넣어주었다. 시각 장애인이 비밀번호 보기를 켜고 끄는건 아무 의미가 없어 보이지만 일단 넣자.
여기까지 했으면 그냥 일반적인 눈모양 켜고 끄기 버튼이 달린 일반적인 인풋이다.
3번 - 좌표값 따기
이제 눈 버튼을 기준으로 현재 마우스 포인터의 위치가 어디인지 따야 한다.
const onClick = () => {
setShowPassword((prev) => !prev);
};
const originRef = useRef<HTMLButtonElement>(null);
return (
<button type='button' className={styles.fancyEyeBall} onClick={onClick} ref={originRef} aria-label='show password'>
{showPassword ? (
<>
<IconEyeTemplate className={styles.eyeTemplate} />
<IconEyeBall className={styles.eyeBall} />
</>
) : (
<IconEyeClosed className={styles.eyeClosed} />
)}
</button>
);
이런 식으로 originRef를 하나 만들어서 버튼 자체에 박아주자. DOM과 인터렉션 할 때는 ref를 쓰는 습관을 들여야한다. ref를 안쓰고 막 document.querySelector 써서 하면 그건 리액트가 아니다.
다음 단계로, 이벤트리스너를 붙여보자.
useEffect(() => {
const onMouseMove = (e: MouseEvent): void => {
if (!originRef.current) return;
const { x: x1, y: y1 } = e;
const { x: x2, y: y2 } = originRef.current.getBoundingClientRect();
};
window.addEventListener('mousemove', onMouseMove);
return () => {
window.removeEventListener('mousemove', onMouseMove);
};
});
윈도우의 mousemove 이벤트를 리스닝해서, onMouseMove를 실행하는 간단한 코드다. 마우스 이벤트인 e는 각각 x와 y 좌표를 갖고 있는데, 이걸 각각 x1, y1으로 가져오자. 이게 현재 마우스 포인터의 위치다.
그 다음은 눈알 버튼의 위치다. originRef.current에서 getBoundingClientRect로 좌표값을 x2, y2로 가져오자. 여기까지는 아주 간단하다.
이제 저 x1, y1, x2, y2를 이용해 지지고 볶으면 끝난다.
4. 각도 구하기
현재 버튼의 위치 x2, y2와 마우스 포인터의 위치 x1, y1을 이용해 현재 각도를 구해보자.
const { x: x1, y: y1 } = e;
const { x: x2, y: y2 } = originRef.current.getBoundingClientRect();
let rad = Math.atan2(y2 - y1, x2 - x1);
if (rad < 0) rad += Math.PI * 2;
rad = (rad * 180) / Math.PI;
Math.atan2를 이용하여 x/y 좌표 만으로 각도를 구할 수 있다. x1, y1 점을 기준점으로 하기 위해 x2 - x1, y2 - y1 해주면 끝. 피타고라스 만세.
5. 각도에 따라 눈알 움직이기
위에서 구한 rad 값을 아래의 스크린샷을 참고하여 대충 상상해보면 된다.
다소 복잡해보이데, 저거 한장이면 설명 다 끝난다. 각도를 구하고, 각도에 따라 눈알을 움직여주면 되는거다.
가운데 빨간 사각형, 즉 눈알 버튼을 기준으로 해서 rad 값이 0-360 까지 마우스를 움직일 때 마다 변경된다. 보자.. 0-22.5도를 Left 상태, 22.5-45-67.5도를 TopLeft 상태, 67.5-90-112.5도를 Top 상태.. 이런 식으로 마킹하는거다.
const [cord, setCord] = useRafState({
top: false,
right: false,
bottom: false,
left: false,
});
...
if (rad < 22.5) {
// L
setCord({
top: false,
right: false,
bottom: false,
left: true,
});
} else if (rad < 67.5) {
// TL
setCord({
top: true,
right: false,
bottom: false,
left: true,
});
} else if (rad < 112.5) {
// T
setCord({
top: true,
right: false,
bottom: false,
left: false,
});
} else if (rad < 157.5) {
// TR
setCord({
top: true,
right: true,
bottom: false,
left: false,
});
} else if (rad < 202.5) {
// R
setCord({
top: false,
right: true,
bottom: false,
left: false,
});
} else if (rad < 247.5) {
// BR
setCord({
top: false,
right: true,
bottom: true,
left: false,
});
} else if (rad < 292.5) {
// B
setCord({
top: false,
right: false,
bottom: true,
left: false,
});
} else if (rad < 337.5) {
// BL
setCord({
top: false,
right: false,
bottom: true,
left: true,
});
} else if (rad < 360) {
// L
setCord({
top: false,
right: false,
bottom: false,
left: true,
});
}
useRafState는 react-use 라이브러리에서 가져온건데, state 변화에 따라 requestAnimationFrame을 사용하기 아주 쉬운 방법이다. 좀 더 스무스한 애니메이션을 위해 넣었다.
분명 더 좋은 코드가 있겠으나, 인간이 볼 코드이므로 Left 상태면 Left만 true, 나머지는 false.. TopLeft 상태면 Left와 Top을 true, 나머지는 false... 이런 식으로 알아보기 쉽게 마킹했다.
<IconEyeBall
className={cx(styles.eyeBall, {
[styles.top]: cord.top,
[styles.bottom]: cord.bottom,
[styles.left]: cord.left,
[styles.right]: cord.right,
})}
/>
그리고 이런 식으로 클래스를 먹인다.
.eyeBall {
@include m.absolute(0 0 0 0);
width: 24px;
height: 24px;
color: var(--color-gray-c);
z-index: var(--z-1);
transition: 100ms;
&.top {
top: -2px;
bottom: auto;
}
&.left {
right: auto;
left: -2px;
}
&.bottom {
top: auto;
bottom: -2px;
}
&.right {
right: -2px;
left: auto;
}
}
그리고 이런 식으로 마크업 되는 식이다. 중간에 mixin으로 들어간 m.absolute는 그냥 마음으로 이해하고 넘어가면 된다. position: absolute에 top right bottom left 순서대로 0 먹인거네- 하고.
이렇게 하면 마우스 움직임에 따라 눈알의 좌표가 변경되며 눈알이 휙휙 돌아가게된다. 완성.
실제로 적용된 전체 소스코드는 여기 FancyEyeBall.tsx 에서 확인할 수 있다.